From 1d26e73556c036edc67bf8f04973937794e9d6f9 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 24 Aug 2026 17:41:25 +0800 Subject: [PATCH 01/15] refactor(core,cli): rename Gemini residue in memory/spinner/leaf ids PR 1 of #4063 item 6 (de-Google naming). Renames three independent families plus the leaf LLM types: - Memory filename: GeminiMd* -> Memory* (project memory file, not an LLM client) - UI spinners: GeminiRespondingSpinner/GeminiSpinner -> RespondingSpinner/Spinner - Leaf types: GeminiCodeRequest/GeminiChatSendOptions/GeminiErrorEventValue/GeminiFinishedEventValue -> Llm* - geminiRequest.ts -> llm-request.ts (and its collocated test) No behavior change. Renamed symbols typecheck clean in core+cli; eslint clean on renamed files. Refs #4063 --- .../2026-08-22-rename-gemini-fork-residue.md | 140 ++++++++++++++++++ .../cli/src/acp-integration/acpAgent.test.ts | 2 +- packages/cli/src/acp-integration/acpAgent.ts | 6 +- packages/cli/src/config/config.test.ts | 24 +-- packages/cli/src/config/config.ts | 10 +- packages/cli/src/core/initializer.test.ts | 4 +- packages/cli/src/core/initializer.ts | 2 +- packages/cli/src/gemini.test.tsx | 20 +-- packages/cli/src/serve/capabilities.ts | 2 +- packages/cli/src/serve/run-qwen-serve.ts | 2 +- .../cli/src/serve/workspace-memory.test.ts | 4 +- packages/cli/src/serve/workspace-memory.ts | 4 +- packages/cli/src/ui/AppContainer.tsx | 16 +- .../src/ui/commands/directoryCommand.test.tsx | 4 +- .../cli/src/ui/commands/directoryCommand.tsx | 4 +- packages/cli/src/ui/commands/initCommand.ts | 4 +- packages/cli/src/ui/commands/types.ts | 2 +- packages/cli/src/ui/components/Footer.tsx | 6 +- .../ui/components/LoadingIndicator.test.tsx | 6 +- .../src/ui/components/LoadingIndicator.tsx | 4 +- .../cli/src/ui/components/MemoryDialog.tsx | 12 +- ...er.test.tsx => RespondingSpinner.test.tsx} | 12 +- ...ndingSpinner.tsx => RespondingSpinner.tsx} | 16 +- .../agent-view/AgentChatContent.tsx | 4 +- .../components/agent-view/AgentComposer.tsx | 2 +- .../messages/CompactToolGroupDisplay.test.tsx | 2 +- .../components/messages/ToolMessage.test.tsx | 4 +- .../components/shared/ToolStatusIndicator.tsx | 4 +- .../ui/hooks/slashCommandProcessor.test.ts | 4 +- .../cli/src/ui/hooks/slashCommandProcessor.ts | 6 +- .../ui/hooks/useAutoAcceptIndicator.test.ts | 4 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 4 +- .../src/ui/hooks/useShowMemoryCommand.test.ts | 2 +- .../cli/src/ui/hooks/useShowMemoryCommand.ts | 2 +- .../src/ui/noninteractive/nonInteractiveUi.ts | 2 +- .../src/config/config-session-env.test.ts | 2 +- .../core/src/config/config.safe-mode.test.ts | 2 +- packages/core/src/config/config.test.ts | 22 +-- packages/core/src/config/config.ts | 12 +- .../config.workflow-registration.test.ts | 2 +- .../core/src/config/config.workflows.test.ts | 2 +- packages/core/src/core/geminiChat.ts | 4 +- ...iniRequest.test.ts => llm-request.test.ts} | 2 +- .../core/{geminiRequest.ts => llm-request.ts} | 4 +- packages/core/src/core/turn.ts | 8 +- packages/core/src/index.ts | 2 +- packages/core/src/memory/const.test.ts | 52 +++---- .../core/src/memory/memoryDiscovery.test.ts | 12 +- packages/core/src/memory/memoryDiscovery.ts | 22 +-- packages/core/src/memory/refresh.test.ts | 6 +- packages/core/src/memory/refresh.ts | 4 +- .../core/src/memory/writeContextFile.test.ts | 12 +- packages/core/src/memory/writeContextFile.ts | 8 +- .../core/src/permissions/autoMode.test.ts | 6 +- packages/core/src/permissions/autoMode.ts | 4 +- packages/core/src/tools/edit.test.ts | 4 +- packages/core/src/tools/glob.test.ts | 2 +- packages/core/src/tools/memory-config.ts | 6 +- packages/core/src/tools/notebook-edit.test.ts | 4 +- packages/core/src/tools/write-file.test.ts | 4 +- .../core/src/utils/ignorePatterns.test.ts | 2 +- packages/core/src/utils/ignorePatterns.ts | 4 +- packages/core/src/utils/memory-constants.ts | 26 ++-- 63 files changed, 363 insertions(+), 223 deletions(-) create mode 100644 docs/design/2026-08-22-rename-gemini-fork-residue.md rename packages/cli/src/ui/components/{GeminiRespondingSpinner.test.tsx => RespondingSpinner.test.tsx} (70%) rename packages/cli/src/ui/components/{GeminiRespondingSpinner.tsx => RespondingSpinner.tsx} (86%) rename packages/core/src/core/{geminiRequest.test.ts => llm-request.test.ts} (97%) rename packages/core/src/core/{geminiRequest.ts => llm-request.ts} (81%) diff --git a/docs/design/2026-08-22-rename-gemini-fork-residue.md b/docs/design/2026-08-22-rename-gemini-fork-residue.md new file mode 100644 index 00000000000..74bd7aa04cc --- /dev/null +++ b/docs/design/2026-08-22-rename-gemini-fork-residue.md @@ -0,0 +1,140 @@ +# Rename `Gemini` fork-residue identifiers to `Llm` + +## Problem + +`#4063` item 6: the codebase still carries the `Gemini` prefix inherited from +the upstream Gemini CLI fork. Measured on `origin/main` (`43d46be912f4`): + +- **~271 files** contain a `Gemini` token. +- **~4000 `Gemini` token occurrences** across `packages/core/src` and + `packages/cli/src`, split into two shapes: + - **PascalCase type/component names** (token-prefix): `GeminiClient`, + `GeminiChat`, `GeminiEventType`, … + - **camelCase function/variable names** (token-infix): `getGeminiClient`, + `convertGeminiRequestToOpenAI`, `useGeminiStream`, `setGeminiMdFilename`, … + +`GeminiClient` is the generic LLM client, not a Gemini-specific type, and the +repo has already adopted the `Llm` prefix elsewhere (`BaseLlmClient`, +`LlmRewriter`, `LlmContent`, `LlmOutputLanguage`, `LlmSpan`). The `Gemini` +residue is brand mismatch that confuses contributors and blocks a coherent +naming scheme. + +## Proposal + +Rename the local `Gemini` identifiers to `Llm`, with these exceptions: + +1. **Memory filename** — `GeminiMdFilename` (+ `set/get/getAll/getCurrent`, + `GeminiMdFileCount`) is the project memory file (`QWEN.md`), a memory + concept, not an LLM client. → `Memory*`. +2. **UI spinners** — `GeminiRespondingSpinner` / `GeminiSpinner` drop the + prefix. → `RespondingSpinner` / `Spinner`. +3. **Gemini extension format (keep as-is)** — `packages/core/src/extension/ + gemini-converter.ts` converts *upstream Gemini CLI extension* configs + (`GeminiExtensionConfig`, `convertGeminiToQwenConfig`, + `convertGeminiExtensionPackage`, `isGeminiExtensionConfig`). The `Gemini` + here denotes a real external format, not the generic LLM client. **Not part + of this rename.** +4. **`@google/genai` SDK types (out of scope)** — `Content`, + `GenerateContentParameters`, `Part`, … are imported from `@google/genai` + (mostly `cli/src/acp-integration`). These belong to `#4063` item 1 + (de-Google the type system), not this rename. + +## Symbol map + +Local type/class/enum names (PascalCase, → `Llm*`): + +| Current | Definition | New | +|---|---|---| +| `GeminiClient` | `core/src/core/client.ts:375` class | `LlmClient` | +| `GeminiChat` | `core/src/core/geminiChat.ts:1853` class | `LlmChat` | +| `GeminiEventType` | `core/src/core/turn.ts:62` **and** `cli/src/ui/types.ts:42` (two enums) | `LlmEventType` | +| `GeminiContentGenerator` | `core/src/core/geminiContentGenerator/geminiContentGenerator.ts:61` class | `LlmContentGenerator` | +| `GeminiCodeRequest` | `core/src/core/geminiRequest.ts:15` type | `LlmCodeRequest` | +| `GeminiChatSendOptions` | `core/src/core/geminiChat.ts:448` interface | `LlmChatSendOptions` | +| `GeminiErrorEventValue` / `GeminiFinishedEventValue` | `core/src/core/turn.ts:112/122` | `LlmErrorEventValue` / `LlmFinishedEventValue` | +| `GeminiRespondingSpinner` / `GeminiSpinner` | `cli/src/ui/components/GeminiRespondingSpinner.tsx:32/59` | `RespondingSpinner` / `Spinner` | + +camelCase functions/variables (token-infix, → `Llm*`), highest-frequency first: + +| Current | New | +|---|---| +| `getGeminiClient` | `getLlmClient` | +| `mockGeminiClient` | `mockLlmClient` | +| `convertOpenAIChunkToGemini` | `convertOpenAIChunkToLlm` | +| `convertGeminiRequestToOpenAI` | `convertLlmRequestToOpenAI` | +| `convertOpenAIResponseToGemini` | `convertOpenAIResponseToLlm` | +| `responseSubmittedToGemini` | `responseSubmittedToLlm` | +| `useGeminiStream` | `useLlmStream` | +| `convertGeminiRequestToAnthropic` | `convertLlmRequestToAnthropic` | +| `setGeminiMdFilename` / `getAllGeminiMdFilenames` / `getCurrentGeminiMdFilename` / `getGeminiMdFileCount` / `setGeminiMdFileCount` | `setMemoryFilename` / `getAllMemoryFilenames` / `getCurrentMemoryFilename` / `getMemoryFileCount` / `setMemoryFileCount` | +| `mockGeminiResponse` / `mockGeminiClientInstance` / `MockedGeminiClientClass` | `mockLlmResponse` / `mockLlmClientInstance` / `MockedLlmClientClass` | +| `convertGeminiToolsToOpenAI` / `convertGeminiToolsToAnthropic` | `convertLlmToolsToOpenAI` / `convertLlmToolsToAnthropic` | +| `convertGeminiToolParametersToOpenAI` | `convertLlmToolParametersToOpenAI` | +| `newGeminiMessageBuffer` / `makeGeminiHistoryItem` / `extractGeminiContent` / `buildGeminiChunk` / `recordGeminiChunk` | `newLlmMessageBuffer` / `makeLlmHistoryItem` / `extractLlmContent` / `buildLlmChunk` / `recordLlmChunk` | +| `createInitializedGeminiClient` / `createGeminiContentGenerator` | `createInitializedLlmClient` / `createLlmContentGenerator` | +| `mapAnthropicFinishReasonToGemini` / `convertAnthropicResponseToGemini` | `mapAnthropicFinishReasonToLlm` / `convertAnthropicResponseToLlm` | +| `pendingGeminiHistoryItems` / `skipGeminiInitialization` / `loadHierarchicalGeminiMemory` | `pendingLlmHistoryItems` / `skipLlmInitialization` / `loadHierarchicalLlmMemory` | + +## File renames + +Non-test files; `gemini-converter.ts` is intentionally NOT renamed (see above). + +| Current | New | +|---|---| +| `packages/cli/src/gemini.tsx` | `packages/cli/src/llm.tsx` | +| `packages/cli/src/ui/components/GeminiRespondingSpinner.tsx` | `packages/cli/src/ui/components/RespondingSpinner.tsx` | +| `packages/cli/src/ui/hooks/useGeminiStream.ts` | `packages/cli/src/ui/hooks/use-llm-stream.ts` | +| `packages/core/src/core/geminiChat.ts` | `packages/core/src/core/llm-chat.ts` | +| `packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts` | `packages/core/src/core/llm-content-generator/llm-content-generator.ts` | +| `packages/core/src/core/geminiContentGenerator/index.ts` | `packages/core/src/core/llm-content-generator/index.ts` | +| `packages/core/src/core/geminiRequest.ts` | `packages/core/src/core/llm-request.ts` | + +## Phasing + +Two pull requests. The core LLM symbols are strongly coupled (`GeminiClient` +holds a `GeminiChat`, converters reference `GeminiEventType`), so the core must +move as one atomic PR. + +**PR 1 — independent small families** (no cross-package risk, small diff): + +- Memory filename: `GeminiMdFilename` family → `Memory*`. +- UI spinners: `GeminiRespondingSpinner` / `GeminiSpinner` → + `RespondingSpinner` / `Spinner`, and `GeminiRespondingSpinner.tsx` → + `RespondingSpinner.tsx`. +- Leaf types: `GeminiCodeRequest`, `GeminiChatSendOptions`, + `GeminiErrorEventValue`, `GeminiFinishedEventValue`, and `geminiRequest.ts` → + `llm-request.ts`. + +**PR 2 — core LLM layer (atomic)**: + +- `GeminiClient` → `LlmClient` (barrel + 4 importing packages) and all + `get/mock/create…GeminiClient`. +- `GeminiChat` → `LlmChat` (`geminiChat.ts` → `llm-chat.ts`), + `GeminiContentGenerator` → `LlmContentGenerator` + (`geminiContentGenerator/` → `llm-content-generator/`). +- `GeminiEventType` (both enums) → `LlmEventType`. +- Stream layer: `useGeminiStream` → `useLlmStream` (`use-llm-stream.ts`), + `gemini.tsx` → `llm.tsx`. +- Protocol converters: `convert*ToGemini*` / `convertGemini*To*` → `Llm`. + +## Risks + +- **git blame loss**: every rename loses history (noted in AGENTS.md). Accept + the cost once; do not rename the same file twice. +- **Cross-package barrel**: `GeminiClient` and `GeminiEventType` are exported via + the `@qwen-code/qwen-code-core` barrel. `sdk-typescript` and `acp-bridge` + import them; PR 2 must update those packages. +- **Two `GeminiEventType` enums**: `core/src/core/turn.ts` and + `cli/src/ui/types.ts` define the same name. Rename both and verify their + relationship (distinct enums vs re-export) before PR 2. +- **Test mock coupling**: when a module moves or a symbol renames, both the + `vi.mock(...)` first argument AND the `typeof import(...)` type annotation + must be updated together (lesson from `#9146`). +- **License headers**: moved files keep the original `2025 Google LLC` header. + +## Verification + +- `cd packages/core && npx tsc --noEmit` +- `cd packages/cli && npx tsc --noEmit` +- Targeted unit tests per renamed module +- `npm run lint` (kebab-case filenames are enforced) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 191812d9843..a68e9ccd0b9 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -572,7 +572,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ }, ), clearCachedCredentialFile: vi.fn(), - getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md', 'AGENTS.md']), + getAllMemoryFilenames: vi.fn(() => ['QWEN.md', 'AGENTS.md']), getAutoMemoryRoot: vi.fn( (projectRoot: string) => `${projectRoot}/.qwen/memory`, ), diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index c8aad29652c..c6e2c5e21c6 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -19,7 +19,7 @@ import { createDebugLogger, generateSessionRecap, findProviderById, - getAllGeminiMdFilenames, + getAllMemoryFilenames, getAutoMemoryRoot, getUserAutoMemoryRoot, getDefaultBaseUrlForProtocol, @@ -2304,7 +2304,7 @@ async function resolvePreferredMemoryFile( dir: string, fallbackFilename: string, ): Promise { - for (const filename of getAllGeminiMdFilenames()) { + for (const filename of getAllMemoryFilenames()) { const filePath = path.join(dir, filename); try { await fs.access(filePath); @@ -2321,7 +2321,7 @@ async function resolveQwenMemoryPaths(params: { cwd: string; projectRoot: string; }): Promise { - const fallbackFilename = getAllGeminiMdFilenames()[0] ?? 'QWEN.md'; + const fallbackFilename = getAllMemoryFilenames()[0] ?? 'QWEN.md'; const userMemoryFile = await resolvePreferredMemoryFile( Storage.getGlobalQwenDir(), fallbackFilename, diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 34f724ec56d..45552fd5e72 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1169,15 +1169,15 @@ describe('loadCliConfig', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(); const settings: Settings = {}; - const setGeminiMdFilenameSpy = vi.spyOn( + const setMemoryFilenameSpy = vi.spyOn( ServerConfig, - 'setGeminiMdFilename', + 'setMemoryFilename', ); await loadCliConfig(settings, argv); - expect(setGeminiMdFilenameSpy).toHaveBeenCalledTimes(1); - expect(setGeminiMdFilenameSpy).toHaveBeenCalledWith([ + expect(setMemoryFilenameSpy).toHaveBeenCalledTimes(1); + expect(setMemoryFilenameSpy).toHaveBeenCalledWith([ ServerConfig.DEFAULT_CONTEXT_FILENAME, ServerConfig.AGENT_CONTEXT_FILENAME, ]); @@ -1270,15 +1270,15 @@ describe('loadCliConfig', () => { fileName: 'CUSTOM_AGENTS.md', }, }; - const setGeminiMdFilenameSpy = vi.spyOn( + const setMemoryFilenameSpy = vi.spyOn( ServerConfig, - 'setGeminiMdFilename', + 'setMemoryFilename', ); await loadCliConfig(settings, argv); - expect(setGeminiMdFilenameSpy).toHaveBeenCalledTimes(1); - expect(setGeminiMdFilenameSpy).toHaveBeenCalledWith('CUSTOM_AGENTS.md'); + expect(setMemoryFilenameSpy).toHaveBeenCalledTimes(1); + expect(setMemoryFilenameSpy).toHaveBeenCalledWith('CUSTOM_AGENTS.md'); }); it('should propagate stream-json formats to config', async () => { @@ -2100,9 +2100,9 @@ describe('loadCliConfig', () => { const settings: Settings = {}; const defaultContextFiles = ['QWEN.md', 'AGENTS.md']; const getAllSpy = vi - .spyOn(ServerConfig, 'getAllGeminiMdFilenames') + .spyOn(ServerConfig, 'getAllMemoryFilenames') .mockReturnValue(defaultContextFiles); - const setFilenameSpy = vi.spyOn(ServerConfig, 'setGeminiMdFilename'); + const setFilenameSpy = vi.spyOn(ServerConfig, 'setMemoryFilename'); await loadCliConfig(settings, argv); @@ -2114,8 +2114,8 @@ describe('loadCliConfig', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(); const settings: Settings = { context: { fileName: 'CUSTOM_CONTEXT.md' } }; - const getAllSpy = vi.spyOn(ServerConfig, 'getAllGeminiMdFilenames'); - const setFilenameSpy = vi.spyOn(ServerConfig, 'setGeminiMdFilename'); + const getAllSpy = vi.spyOn(ServerConfig, 'getAllMemoryFilenames'); + const setFilenameSpy = vi.spyOn(ServerConfig, 'setMemoryFilename'); await loadCliConfig(settings, argv); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 78d166edfb8..c8b74cb59b9 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -12,11 +12,11 @@ import { Config, DEFAULT_QWEN_EMBEDDING_MODEL, FileDiscoveryService, - getAllGeminiMdFilenames, + getAllMemoryFilenames, loadServerHierarchicalMemory, type LoadServerHierarchicalMemoryOptions, type LoadServerHierarchicalMemoryResponse, - setGeminiMdFilename as setServerGeminiMdFilename, + setMemoryFilename as setServerMemoryFilename, resolveTelemetrySettings, FatalConfigError, Storage, @@ -1613,13 +1613,13 @@ export async function loadCliConfig( // Set the context filename in the server's memoryTool module BEFORE loading memory // TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed - // directly to the Config constructor in core, and have core handle setGeminiMdFilename. + // directly to the Config constructor in core, and have core handle setMemoryFilename. // However, loadHierarchicalGeminiMemory is called *before* createServerConfig. if (settings.context?.fileName) { - setServerGeminiMdFilename(settings.context.fileName); + setServerMemoryFilename(settings.context.fileName); } else { // Reset to default context filenames if not provided in settings. - setServerGeminiMdFilename(getAllGeminiMdFilenames()); + setServerMemoryFilename(getAllMemoryFilenames()); } // Automatically load output-language.md if it exists diff --git a/packages/cli/src/core/initializer.test.ts b/packages/cli/src/core/initializer.test.ts index f57a1d8f6e2..84c33fe8460 100644 --- a/packages/cli/src/core/initializer.test.ts +++ b/packages/cli/src/core/initializer.test.ts @@ -44,7 +44,7 @@ describe('initializeApp', () => { let mockConfig: { getModelsConfig: ReturnType; getIdeMode: ReturnType; - getGeminiMdFileCount: ReturnType; + getMemoryFileCount: ReturnType; }; let mockSettings: { merged: Record; @@ -60,7 +60,7 @@ describe('initializeApp', () => { wasAuthTypeExplicitlyProvided: vi.fn().mockReturnValue(false), }), getIdeMode: vi.fn().mockReturnValue(false), - getGeminiMdFileCount: vi.fn().mockReturnValue(0), + getMemoryFileCount: vi.fn().mockReturnValue(0), }; mockSettings = { diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts index 0e256b2aa79..a67626aa513 100644 --- a/packages/cli/src/core/initializer.ts +++ b/packages/cli/src/core/initializer.ts @@ -82,6 +82,6 @@ export async function initializeApp( authError, themeError, shouldOpenAuthDialog, - geminiMdFileCount: config.getGeminiMdFileCount(), + geminiMdFileCount: config.getMemoryFileCount(), }; } diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 583ba0715b4..85cc346974a 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -414,7 +414,7 @@ describe('gemini.tsx main function', () => { getIdeMode: () => false, getExperimentalZedIntegration: () => false, getScreenReader: () => false, - getGeminiMdFileCount: () => 0, + getMemoryFileCount: () => 0, getProjectRoot: () => '/', getOutputFormat: () => OutputFormat.TEXT, getWarnings: () => [], @@ -811,7 +811,7 @@ describe('gemini.tsx main function', () => { getIdeMode: () => false, getExperimentalZedIntegration: () => false, getScreenReader: () => false, - getGeminiMdFileCount: () => 0, + getMemoryFileCount: () => 0, getProjectRoot: () => '/', getOutputFormat: () => OutputFormat.TEXT, getWarnings: () => [], @@ -1107,7 +1107,7 @@ describe('gemini.tsx main function', () => { getIdeMode: () => false, getExperimentalZedIntegration: () => false, getScreenReader: () => false, - getGeminiMdFileCount: () => 0, + getMemoryFileCount: () => 0, getProjectRoot: () => '/', getOutputFormat: () => OutputFormat.TEXT, getWarnings: () => (initialized ? ['late memory warning'] : []), @@ -1525,7 +1525,7 @@ describe('gemini.tsx main function', () => { getIdeMode: () => false, getExperimentalZedIntegration: () => false, getScreenReader: () => false, - getGeminiMdFileCount: () => 0, + getMemoryFileCount: () => 0, getProjectRoot: () => '/', getInputFormat: () => 'stream-json', getContentGeneratorConfig: () => ({ authType: 'test-auth' }), @@ -1721,7 +1721,7 @@ describe('gemini.tsx main function kitty protocol', () => { getIdeMode: () => false, getExperimentalZedIntegration: () => false, getScreenReader: () => false, - getGeminiMdFileCount: () => 0, + getMemoryFileCount: () => 0, getWarnings: () => [], isSafeMode: () => false, getModelsConfig: () => ({ getCurrentAuthType: () => null }), @@ -1848,7 +1848,7 @@ describe('gemini.tsx main function kitty protocol', () => { getIdeMode: () => false, getExperimentalZedIntegration: () => false, getScreenReader: () => false, - getGeminiMdFileCount: () => 0, + getMemoryFileCount: () => 0, getWarnings: () => [], isSafeMode: () => false, getModelsConfig: () => ({ getCurrentAuthType: () => null }), @@ -1974,7 +1974,7 @@ describe('gemini.tsx main function kitty protocol', () => { getIdeMode: () => false, getExperimentalZedIntegration: () => false, getScreenReader: () => false, - getGeminiMdFileCount: () => 0, + getMemoryFileCount: () => 0, getWarnings: () => [], isSafeMode: () => false, getModelsConfig: () => ({ getCurrentAuthType: () => null }), @@ -2097,7 +2097,7 @@ describe('gemini.tsx main function kitty protocol', () => { getIdeMode: () => false, getExperimentalZedIntegration: () => true, getScreenReader: () => false, - getGeminiMdFileCount: () => 0, + getMemoryFileCount: () => 0, getWarnings: () => [], isSafeMode: () => false, getModelsConfig: () => ({ getCurrentAuthType: () => null }), @@ -2239,7 +2239,7 @@ describe('gemini.tsx main function kitty protocol', () => { getIdeMode: () => false, getExperimentalZedIntegration: () => false, getScreenReader: () => false, - getGeminiMdFileCount: () => 0, + getMemoryFileCount: () => 0, getWarnings: () => [], isSafeMode: () => false, getModelsConfig: () => ({ @@ -2558,7 +2558,7 @@ describe('gemini.tsx main function kitty protocol', () => { getIdeMode: () => false, getExperimentalZedIntegration: () => false, getScreenReader: () => false, - getGeminiMdFileCount: () => 0, + getMemoryFileCount: () => 0, getWarnings: () => [], isSafeMode: () => false, getModelsConfig: () => ({ getCurrentAuthType: () => null }), diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 46044d94f72..b77608d27a2 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -207,7 +207,7 @@ export const SERVE_CAPABILITY_REGISTRY = { // without restarting the daemon. V2 trust status exposes convergence. workspace_trust_hot_reload: { since: 'v1' }, // `POST /workspace/init` scaffolds an empty - // `QWEN.md` (or whatever `getCurrentGeminiMdFilename()` returns) at + // `QWEN.md` (or whatever `getCurrentMemoryFilename()` returns) at // the bound workspace root. Body: `{force?: boolean}`. Default // refuses with 409 when the file already exists; `force: true` // overwrites. Mechanical only — does NOT call the LLM. To AI-fill diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index a68cabcf1e2..9b3c6f49329 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -697,7 +697,7 @@ export function formatChannelWorkerDaemonUrl( * - anything else (object, number, boolean, undefined) → undefined * * Returning `undefined` is the bridge's signal to use its own - * `getCurrentGeminiMdFilename()` default — so a malformed value + * `getCurrentMemoryFilename()` default — so a malformed value * keeps the daemon alive rather than producing a garbage filename. */ export function extractContextFilename(value: unknown): string | undefined { diff --git a/packages/cli/src/serve/workspace-memory.test.ts b/packages/cli/src/serve/workspace-memory.test.ts index 861de9edb65..bd8d1bb4675 100644 --- a/packages/cli/src/serve/workspace-memory.test.ts +++ b/packages/cli/src/serve/workspace-memory.test.ts @@ -22,7 +22,7 @@ import { AGENT_CONTEXT_FILENAME, DEFAULT_CONTEXT_FILENAME, Storage, - setGeminiMdFilename, + setMemoryFilename, } from '@qwen-code/qwen-code-core'; import { createMutationGate } from './auth.js'; import { @@ -166,7 +166,7 @@ function buildApp(opts: { } function resetContextFilenames(): void { - setGeminiMdFilename([DEFAULT_CONTEXT_FILENAME, AGENT_CONTEXT_FILENAME]); + setMemoryFilename([DEFAULT_CONTEXT_FILENAME, AGENT_CONTEXT_FILENAME]); } describe('workspace memory routes', () => { diff --git a/packages/cli/src/serve/workspace-memory.ts b/packages/cli/src/serve/workspace-memory.ts index 481bcbaca59..c436f42ebcb 100644 --- a/packages/cli/src/serve/workspace-memory.ts +++ b/packages/cli/src/serve/workspace-memory.ts @@ -11,7 +11,7 @@ import { Storage, WorkspaceMemoryFileTooLargeError, WorkspaceMemoryWriteTimeoutError, - getAllGeminiMdFilenames, + getAllMemoryFilenames, writeWorkspaceContextFile, } from '@qwen-code/qwen-code-core'; import { writeStderrLine } from '../utils/stdioHelpers.js'; @@ -502,7 +502,7 @@ interface DiscoveredFile { export async function collectWorkspaceMemoryStatus( boundWorkspace: string, ): Promise { - const filenames = new Set(getAllGeminiMdFilenames()); + const filenames = new Set(getAllMemoryFilenames()); const files: DiscoveredFile[] = []; const errors: ServeWorkspaceMemoryStatus['errors'] = []; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index edd9e5e15c1..87e22617d96 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -42,7 +42,7 @@ import { ideContextStore, createDebugLogger, getErrorMessage, - getAllGeminiMdFilenames, + getAllMemoryFilenames, ShellExecutionService, Storage, createInstructionsLoadedCallback, @@ -684,7 +684,7 @@ export const AppContainer = (props: AppContainerProps) => { const [isProcessing, setIsProcessing] = useState(false); const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false); - const [geminiMdFileCount, setGeminiMdFileCount] = useState( + const [geminiMdFileCount, setMemoryFileCount] = useState( initializationResult.geminiMdFileCount, ); const [shellModeActive, setShellModeActive] = useState(false); @@ -1927,7 +1927,7 @@ export const AppContainer = (props: AppContainerProps) => { isProcessing, setIsProcessing, isIdleRef, - setGeminiMdFileCount, + setMemoryFileCount, slashCommandActions, extensionsUpdateStateInternal, isConfigInitialized, @@ -2063,12 +2063,12 @@ export const AppContainer = (props: AppContainerProps) => { // Safe mode: skip all context file loading, matching refreshHierarchicalMemory() if (config.isSafeMode()) { config.setUserMemory(''); - config.setGeminiMdFileCount(0); + config.setMemoryFileCount(0); config.setContextFilePaths([]); config.setConditionalRulesRegistry( new ConditionalRulesRegistry([], config.getWorkingDir()), ); - setGeminiMdFileCount(0); + setMemoryFileCount(0); historyManager.addItem( { type: MessageType.INFO, @@ -2112,12 +2112,12 @@ export const AppContainer = (props: AppContainerProps) => { ); config.setUserMemory(memoryContent); - config.setGeminiMdFileCount(fileCount); + config.setMemoryFileCount(fileCount); config.setContextFilePaths(contextFilePaths); config.setConditionalRulesRegistry( new ConditionalRulesRegistry(conditionalRules, projectRoot), ); - setGeminiMdFileCount(fileCount); + setMemoryFileCount(fileCount); historyManager.addItem( { @@ -3159,7 +3159,7 @@ export const AppContainer = (props: AppContainerProps) => { ? Array.isArray(fromSettings) ? fromSettings : [fromSettings] - : getAllGeminiMdFilenames(); + : getAllMemoryFilenames(); }, [settings.merged.context?.fileName]); // Initial prompt handling const initialPrompt = useMemo(() => config.getQuestion(), [config]); diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index a64e1c2abe3..5d44de70004 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -87,7 +87,7 @@ describe('directoryCommand', () => { getExtensionContextFilePaths: () => [], getFileFilteringOptions: () => ({ ignore: [], include: [] }), setUserMemory: vi.fn(), - setGeminiMdFileCount: vi.fn(), + setMemoryFileCount: vi.fn(), } as unknown as Config; mockContext = { @@ -263,7 +263,7 @@ describe('directoryCommand', () => { mockConfig.getContextRuleExcludes = vi.fn().mockReturnValue([]); mockConfig.setContextFilePaths = vi.fn(); mockConfig.setConditionalRulesRegistry = vi.fn(); - mockContext.ui.setGeminiMdFileCount = vi.fn(); + mockContext.ui.setMemoryFileCount = vi.fn(); if (!addCommand?.action) throw new Error('No action'); await addCommand.action( diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index b27db975830..ef8e9be1709 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -258,12 +258,12 @@ export const directoryCommand: SlashCommand = { config.getContextRuleExcludes(), ); config.setUserMemory(memoryContent); - config.setGeminiMdFileCount(fileCount); + config.setMemoryFileCount(fileCount); config.setContextFilePaths(contextFilePaths); config.setConditionalRulesRegistry( new ConditionalRulesRegistry(conditionalRules, projectRoot), ); - context.ui.setGeminiMdFileCount(fileCount); + context.ui.setMemoryFileCount(fileCount); messages.push( t( 'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}', diff --git a/packages/cli/src/ui/commands/initCommand.ts b/packages/cli/src/ui/commands/initCommand.ts index de879c34582..8794017fc89 100644 --- a/packages/cli/src/ui/commands/initCommand.ts +++ b/packages/cli/src/ui/commands/initCommand.ts @@ -11,7 +11,7 @@ import type { SlashCommand, SlashCommandActionReturn, } from './types.js'; -import { getCurrentGeminiMdFilename } from '@qwen-code/qwen-code-core'; +import { getCurrentMemoryFilename } from '@qwen-code/qwen-code-core'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; @@ -34,7 +34,7 @@ export const initCommand: SlashCommand = { }; } const targetDir = context.services.config.getTargetDir(); - const contextFileName = getCurrentGeminiMdFilename(); + const contextFileName = getCurrentMemoryFilename(); const contextFilePath = path.join(targetDir, contextFileName); try { diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 0f7b3396081..d6621dbc409 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -97,7 +97,7 @@ export interface CommandContext { /** Refreshes the static history display in Ink. */ refreshStatic: () => void; toggleVimEnabled: () => Promise; - setGeminiMdFileCount: (count: number) => void; + setMemoryFileCount: (count: number) => void; reloadCommands: () => void | Promise; setSessionName: (name: string | null) => void; extensionsUpdateState: Map; diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index 8724a987ee3..e9a52b3ccda 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -22,7 +22,7 @@ import { useUIState } from '../contexts/UIStateContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useVimModeState } from '../contexts/VimModeContext.js'; -import { GeminiSpinner } from './GeminiRespondingSpinner.js'; +import { Spinner } from './RespondingSpinner.js'; import { GoalPill, isLiveGoalSnapshot, @@ -129,11 +129,11 @@ export const Footer: React.FC = ({ containerRef }) => { ) : configInitMessage ? ( - {configInitMessage} + {configInitMessage} ) : uiState.startupIdeConnectionStatus.state === 'connecting' ? ( - {t('IDE connecting... context may be unavailable')} + {t('IDE connecting... context may be unavailable')} ) : uiState.startupIdeConnectionStatus.state === 'failed' ? ( diff --git a/packages/cli/src/ui/components/LoadingIndicator.test.tsx b/packages/cli/src/ui/components/LoadingIndicator.test.tsx index ffd32dd5729..789d3f664e1 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx @@ -13,9 +13,9 @@ import { StreamingState } from '../types.js'; import { vi } from 'vitest'; import * as useTerminalSize from '../hooks/useTerminalSize.js'; -// Mock GeminiRespondingSpinner -vi.mock('./GeminiRespondingSpinner.js', () => ({ - GeminiRespondingSpinner: ({ +// Mock RespondingSpinner +vi.mock('./RespondingSpinner.js', () => ({ + RespondingSpinner: ({ nonRespondingDisplay, }: { nonRespondingDisplay?: string; diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index fa1571be42a..cbdbec6f5c3 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -10,7 +10,7 @@ import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { useStreamingContext } from '../contexts/StreamingContext.js'; import { StreamingState } from '../types.js'; -import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js'; +import { RespondingSpinner } from './RespondingSpinner.js'; import { formatDuration, formatTokenCount } from '../utils/formatters.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { useAnimationFrame } from '../hooks/useAnimationFrame.js'; @@ -126,7 +126,7 @@ export const LoadingIndicator: React.FC = ({ > - { - for (const filename of getAllGeminiMdFilenames()) { + for (const filename of getAllMemoryFilenames()) { const filePath = path.join(dir, filename); try { await fs.access(filePath); @@ -150,7 +150,7 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) { () => path.join( Storage.getGlobalQwenDir(), - getAllGeminiMdFilenames()[0] ?? 'QWEN.md', + getAllMemoryFilenames()[0] ?? 'QWEN.md', ), [], ); @@ -158,7 +158,7 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) { () => path.join( config.getWorkingDir(), - getAllGeminiMdFilenames()[0] ?? 'QWEN.md', + getAllMemoryFilenames()[0] ?? 'QWEN.md', ), [config], ); @@ -273,12 +273,12 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) { case 'project': return resolvePreferredMemoryFile( config.getWorkingDir(), - getAllGeminiMdFilenames()[0] ?? 'QWEN.md', + getAllMemoryFilenames()[0] ?? 'QWEN.md', ); case 'global': return resolvePreferredMemoryFile( Storage.getGlobalQwenDir(), - getAllGeminiMdFilenames()[0] ?? 'QWEN.md', + getAllMemoryFilenames()[0] ?? 'QWEN.md', ); default: { const _exhaustive: never = item.value; diff --git a/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx b/packages/cli/src/ui/components/RespondingSpinner.test.tsx similarity index 70% rename from packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx rename to packages/cli/src/ui/components/RespondingSpinner.test.tsx index 979a946de89..9596cc19524 100644 --- a/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx +++ b/packages/cli/src/ui/components/RespondingSpinner.test.tsx @@ -7,9 +7,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { render } from 'ink-testing-library'; import { Text } from 'ink'; -import { GeminiSpinner } from './GeminiRespondingSpinner.js'; +import { Spinner } from './RespondingSpinner.js'; -describe('', () => { +describe('', () => { afterEach(() => { vi.unstubAllEnvs(); }); @@ -17,13 +17,13 @@ describe('', () => { it('uses a low-frequency fixed-width indicator inside tmux', () => { vi.stubEnv('TMUX', '/tmp/tmux-1000/default,12345,0'); - const { lastFrame } = render(); + const { lastFrame } = render(); expect(lastFrame()).toContain('.'); }); - // Regression: Footer.tsx renders inside a wrapper - // ('... {msg}'). Ink forbids from being + // Regression: Footer.tsx renders inside a wrapper + // ('... {msg}'). Ink forbids from being // nested inside , so the tmux branch must return a , not a // -wrapped one — otherwise the CLI throws on startup inside tmux. it('renders without throwing when nested inside a (Footer context)', () => { @@ -32,7 +32,7 @@ describe('', () => { expect(() => render( - startup message + startup message , ), ).not.toThrow(); diff --git a/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx b/packages/cli/src/ui/components/RespondingSpinner.tsx similarity index 86% rename from packages/cli/src/ui/components/GeminiRespondingSpinner.tsx rename to packages/cli/src/ui/components/RespondingSpinner.tsx index a89297d9e30..119b2998d90 100644 --- a/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx +++ b/packages/cli/src/ui/components/RespondingSpinner.tsx @@ -20,7 +20,7 @@ import { theme } from '../semantic-colors.js'; const TMUX_SPINNER_INTERVAL_MS = 750; const TMUX_SPINNER_FRAMES = ['. ', '..']; -interface GeminiRespondingSpinnerProps { +interface RespondingSpinnerProps { /** * Optional string to display when not in Responding state. * If not provided and not Responding, renders null. @@ -29,14 +29,14 @@ interface GeminiRespondingSpinnerProps { spinnerType?: SpinnerName; } -export const GeminiRespondingSpinner: React.FC< - GeminiRespondingSpinnerProps +export const RespondingSpinner: React.FC< + RespondingSpinnerProps > = ({ nonRespondingDisplay, spinnerType = 'dots' }) => { const streamingState = useStreamingContext(); const isScreenReaderEnabled = useIsScreenReaderEnabled(); if (streamingState === StreamingState.Responding) { return ( - @@ -51,12 +51,12 @@ export const GeminiRespondingSpinner: React.FC< return null; }; -interface GeminiSpinnerProps { +interface SpinnerProps { spinnerType?: SpinnerName; altText?: string; } -export const GeminiSpinner: React.FC = ({ +export const Spinner: React.FC = ({ spinnerType = 'dots', altText, }) => { @@ -81,8 +81,8 @@ export const GeminiSpinner: React.FC = ({ } if (isTmux) { - // Note: must NOT wrap in here — GeminiSpinner is rendered inside a - // in Footer.tsx (`... {msg}`), and + // Note: must NOT wrap in here — Spinner is rendered inside a + // in Footer.tsx (`... {msg}`), and // Ink forbids nested inside . The 2-char fixed-width frames // already give us stable layout without an explicit width container. return ( diff --git a/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx b/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx index c86b79d0d81..3b017cadae6 100644 --- a/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx @@ -27,7 +27,7 @@ import { useAgentViewActions } from '../../contexts/AgentViewContext.js'; import { HistoryItemDisplay } from '../HistoryItemDisplay.js'; import { ToolCallStatus } from '../../types.js'; import { theme } from '../../semantic-colors.js'; -import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js'; +import { RespondingSpinner } from '../RespondingSpinner.js'; import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js'; import { AgentHeader } from './AgentHeader.js'; import { buildThoughtHeadIdMap } from '../../utils/historyUtils.js'; @@ -267,7 +267,7 @@ export const AgentChatContent = ({ {/* Spinner */} {isRunning && ( - + )} diff --git a/packages/cli/src/ui/components/agent-view/AgentComposer.tsx b/packages/cli/src/ui/components/agent-view/AgentComposer.tsx index d64298d8239..f9704a04bd8 100644 --- a/packages/cli/src/ui/components/agent-view/AgentComposer.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentComposer.tsx @@ -13,7 +13,7 @@ * - Keyboard events are scoped — no conflict with the main InputPrompt * * Wraps its content in a local StreamingContext.Provider so reusable - * components like LoadingIndicator and GeminiRespondingSpinner read the + * components like LoadingIndicator and RespondingSpinner read the * agent's derived streaming state instead of the main agent's. */ diff --git a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx index a689eb2edf3..cf24d51aedd 100644 --- a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx @@ -16,7 +16,7 @@ import { import { ToolCallStatus } from '../../types.js'; import type { IndividualToolCallDisplay } from '../../types.js'; -// ToolStatusIndicator pulls in GeminiRespondingSpinner which requires +// ToolStatusIndicator pulls in RespondingSpinner which requires // StreamingContext; stub the component but keep the real constant so // height-estimation tests stay in sync with production. vi.mock('../shared/ToolStatusIndicator.js', async (importOriginal) => ({ diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index bbc774768e8..89d456d2fe4 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -89,8 +89,8 @@ vi.mock('../TerminalImage.js', () => ({ })); // Mock child components or utilities if they are complex or have side effects -vi.mock('../GeminiRespondingSpinner.js', () => ({ - GeminiRespondingSpinner: ({ +vi.mock('../RespondingSpinner.js', () => ({ + RespondingSpinner: ({ nonRespondingDisplay, }: { nonRespondingDisplay?: string; diff --git a/packages/cli/src/ui/components/shared/ToolStatusIndicator.tsx b/packages/cli/src/ui/components/shared/ToolStatusIndicator.tsx index bc888f9e59c..16163048e41 100644 --- a/packages/cli/src/ui/components/shared/ToolStatusIndicator.tsx +++ b/packages/cli/src/ui/components/shared/ToolStatusIndicator.tsx @@ -7,7 +7,7 @@ import type React from 'react'; import { Box, Text } from 'ink'; import { ToolCallStatus } from '../../types.js'; -import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js'; +import { RespondingSpinner } from '../RespondingSpinner.js'; import { TOOL_STATUS, SHELL_COMMAND_NAME, @@ -42,7 +42,7 @@ export const ToolStatusIndicator: React.FC = ({ {TOOL_STATUS.PENDING} )} {status === ToolCallStatus.Executing && ( - diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 24f771a84bb..ba1bbc52d37 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -252,7 +252,7 @@ describe('useSlashCommandProcessor', () => { false, // isProcessing setIsProcessing, isIdleRef, - vi.fn(), // setGeminiMdFileCount + vi.fn(), // setMemoryFileCount createMockActions(), new Map(), // extensionsUpdateState true, // isConfigInitialized @@ -2352,7 +2352,7 @@ describe('useSlashCommandProcessor', () => { false, // isProcessing vi.fn(), // setIsProcessing { current: true }, // isIdleRef - vi.fn(), // setGeminiMdFileCount + vi.fn(), // setMemoryFileCount createMockActions(), new Map(), // extensionsUpdateState true, // isConfigInitialized diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index b02ee3da803..65d43800e04 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -222,7 +222,7 @@ export const useSlashCommandProcessor = ( isProcessing: boolean, setIsProcessing: (isProcessing: boolean) => void, isIdleRef: MutableRefObject, - setGeminiMdFileCount: (count: number) => void, + setMemoryFileCount: (count: number) => void, actions: SlashCommandProcessorActions, extensionsUpdateState: Map, isConfigInitialized: boolean, @@ -539,7 +539,7 @@ export const useSlashCommandProcessor = ( btwAbortControllerRef, isIdleRef, toggleVimEnabled, - setGeminiMdFileCount, + setMemoryFileCount, reloadCommands, setSessionName: setSessionName ?? (() => {}), extensionsUpdateState, @@ -572,7 +572,7 @@ export const useSlashCommandProcessor = ( cancelBtw, toggleVimEnabled, sessionShellAllowlist, - setGeminiMdFileCount, + setMemoryFileCount, reloadCommands, setSessionName, extensionsUpdateState, diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts index 05a397e3e63..f43d92e08af 100644 --- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts +++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts @@ -54,7 +54,7 @@ interface MockConfigInstanceShape { getFullContext: Mock<() => boolean>; getUserAgent: Mock<() => string>; getUserMemory: Mock<() => string>; - getGeminiMdFileCount: Mock<() => number>; + getMemoryFileCount: Mock<() => number>; getToolRegistry: Mock<() => { discoverTools: Mock<() => void> }>; } @@ -110,7 +110,7 @@ describe('useAutoAcceptIndicator', () => { () => string >, getUserMemory: vi.fn().mockReturnValue('') as Mock<() => string>, - getGeminiMdFileCount: vi.fn().mockReturnValue(0) as Mock<() => number>, + getMemoryFileCount: vi.fn().mockReturnValue(0) as Mock<() => number>, getToolRegistry: vi .fn() .mockReturnValue({ discoverTools: vi.fn() }) as Mock< diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 0b0cc0454ad..02d586c1ec2 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -26,7 +26,7 @@ import { type ThoughtSummary, type ToolCallRequestInfo, type ToolCallResponseInfo, - type GeminiErrorEventValue, + type LlmErrorEventValue, type GoalTurnPermit, type SteerInput, GeminiEventType as ServerGeminiEventType, @@ -2095,7 +2095,7 @@ export const useGeminiStream = ( const handleErrorEvent = useCallback( ( - eventValue: GeminiErrorEventValue, + eventValue: LlmErrorEventValue, userMessageTimestamp: number, submitType: SendMessageType, ) => { diff --git a/packages/cli/src/ui/hooks/useShowMemoryCommand.test.ts b/packages/cli/src/ui/hooks/useShowMemoryCommand.test.ts index aa5e9b19e51..91024679897 100644 --- a/packages/cli/src/ui/hooks/useShowMemoryCommand.test.ts +++ b/packages/cli/src/ui/hooks/useShowMemoryCommand.test.ts @@ -38,7 +38,7 @@ function createMockConfig({ return { getUserMemory: () => userMemory, getAutoMemoryPrompt: () => autoMemoryPrompt, - getGeminiMdFileCount: () => fileCount, + getMemoryFileCount: () => fileCount, } as unknown as Config; } diff --git a/packages/cli/src/ui/hooks/useShowMemoryCommand.ts b/packages/cli/src/ui/hooks/useShowMemoryCommand.ts index 47ca472e169..7f0a0e93d22 100644 --- a/packages/cli/src/ui/hooks/useShowMemoryCommand.ts +++ b/packages/cli/src/ui/hooks/useShowMemoryCommand.ts @@ -34,7 +34,7 @@ export function createShowMemoryAction( const currentMemory = [contextMemory, autoMemoryPrompt] .filter((section) => section.trim().length > 0) .join('\n\n---\n\n'); - const fileCount = config.getGeminiMdFileCount(); + const fileCount = config.getMemoryFileCount(); const contextFileName = settings.merged.context?.fileName; const contextFileNames = Array.isArray(contextFileName) ? contextFileName diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts index e9380f61fb9..81cacdea815 100644 --- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts @@ -28,7 +28,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] { btwAbortControllerRef: { current: null }, isIdleRef: { current: true }, toggleVimEnabled: async () => false, - setGeminiMdFileCount: (_count) => {}, + setMemoryFileCount: (_count) => {}, reloadCommands: () => {}, setSessionName: () => {}, extensionsUpdateState: new Map(), diff --git a/packages/core/src/config/config-session-env.test.ts b/packages/core/src/config/config-session-env.test.ts index ff3fba98df8..a9e596a9c21 100644 --- a/packages/core/src/config/config-session-env.test.ts +++ b/packages/core/src/config/config-session-env.test.ts @@ -89,7 +89,7 @@ vi.mock('../ide/ide-client.js', () => ({ }, })); vi.mock('../utils/memory-constants.js', () => ({ - setGeminiMdFilename: vi.fn(), + setMemoryFilename: vi.fn(), })); import * as fs from 'node:fs'; diff --git a/packages/core/src/config/config.safe-mode.test.ts b/packages/core/src/config/config.safe-mode.test.ts index 954e1fc0388..cf9524f8cdf 100644 --- a/packages/core/src/config/config.safe-mode.test.ts +++ b/packages/core/src/config/config.safe-mode.test.ts @@ -469,7 +469,7 @@ describe('Config safe mode', () => { await config.initialize(); expect(config.getUserMemory()).toBe(''); expect(config.getAutoMemoryPrompt()).toBe(''); - expect(config.getGeminiMdFileCount()).toBe(0); + expect(config.getMemoryFileCount()).toBe(0); }); it('records every fixed Config startup phase in order when skipped', async () => { diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 844ec875d45..89fc1f5e2e0 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -25,7 +25,7 @@ import { DEFAULT_MAX_TOOL_CALLS_PER_TURN } from '../services/loopDetectionServic import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { setGeminiMdFilename as mockSetGeminiMdFilename } from '../utils/memory-constants.js'; +import { setMemoryFilename as mockSetMemoryFilename } from '../utils/memory-constants.js'; import { DEFAULT_TELEMETRY_TARGET, DEFAULT_OTLP_ENDPOINT, @@ -289,15 +289,15 @@ vi.mock('../tools/read-many-files', () => ({ ReadManyFilesTool: createToolMock('read_many_files'), })); vi.mock('../utils/memory-constants.js', () => ({ - setGeminiMdFilename: vi.fn(), - getCurrentGeminiMdFilename: vi.fn(() => 'QWEN.md'), // Mock the original filename - getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md', 'AGENTS.md']), + setMemoryFilename: vi.fn(), + getCurrentMemoryFilename: vi.fn(() => 'QWEN.md'), // Mock the original filename + getAllMemoryFilenames: vi.fn(() => ['QWEN.md', 'AGENTS.md']), DEFAULT_CONTEXT_FILENAME: 'QWEN.md', })); vi.mock('../tools/memory-config', () => ({ - setGeminiMdFilename: vi.fn(), - getCurrentGeminiMdFilename: vi.fn(() => 'QWEN.md'), - getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md', 'AGENTS.md']), + setMemoryFilename: vi.fn(), + getCurrentMemoryFilename: vi.fn(() => 'QWEN.md'), + getAllMemoryFilenames: vi.fn(() => ['QWEN.md', 'AGENTS.md']), DEFAULT_CONTEXT_FILENAME: 'QWEN.md', AGENT_CONTEXT_FILENAME: 'AGENTS.md', MEMORY_SECTION_HEADER: '## Qwen Added Memories', @@ -7508,19 +7508,19 @@ describe('Server Config (config.ts)', () => { ); }); - it('Config constructor should call setGeminiMdFilename with contextFileName if provided', () => { + it('Config constructor should call setMemoryFilename with contextFileName if provided', () => { const contextFileName = 'CUSTOM_AGENTS.md'; const paramsWithContextFile: ConfigParameters = { ...baseParams, contextFileName, }; new Config(paramsWithContextFile); - expect(mockSetGeminiMdFilename).toHaveBeenCalledWith(contextFileName); + expect(mockSetMemoryFilename).toHaveBeenCalledWith(contextFileName); }); - it('Config constructor should not call setGeminiMdFilename if contextFileName is not provided', () => { + it('Config constructor should not call setMemoryFilename if contextFileName is not provided', () => { new Config(baseParams); // baseParams does not have contextFileName - expect(mockSetGeminiMdFilename).not.toHaveBeenCalled(); + expect(mockSetMemoryFilename).not.toHaveBeenCalled(); }); it('should set default file filtering settings when not provided', () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index a49dce42449..52dba22629c 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -80,7 +80,7 @@ import { getMCPServerStatus, type SendSdkMcpMessage, } from '../tools/mcp-client.js'; -import { setGeminiMdFilename } from '../utils/memory-constants.js'; +import { setMemoryFilename } from '../utils/memory-constants.js'; import { canUseRipgrep } from '../utils/ripgrepUtils.js'; import { recordStartupEvent } from '../utils/startupEventSink.js'; import { ToolRegistry, type ToolFactory } from '../tools/tool-registry.js'; @@ -2573,7 +2573,7 @@ export class Config { }); this.worktreeSettings = params.worktree ?? {}; if (params.contextFileName) { - setGeminiMdFilename(params.contextFileName); + setMemoryFilename(params.contextFileName); } // Create ModelsConfig for centralized model management @@ -3581,7 +3581,7 @@ export class Config { if (this.isSafeMode()) { this.setUserMemory(''); this.autoMemoryPrompt = ''; - this.setGeminiMdFileCount(0); + this.setMemoryFileCount(0); this.setContextFilePaths([]); this.conditionalRulesRegistry = new ConditionalRulesRegistry( [], @@ -3738,7 +3738,7 @@ export class Config { this.setUserMemory(memoryContent); this.autoMemoryPrompt = ''; } - this.setGeminiMdFileCount(fileCount); + this.setMemoryFileCount(fileCount); this.setContextFilePaths(contextFilePaths); this.conditionalRulesRegistry = new ConditionalRulesRegistry( conditionalRules, @@ -6377,11 +6377,11 @@ export class Config { this.userMemory = newUserMemory; } - getGeminiMdFileCount(): number { + getMemoryFileCount(): number { return this.geminiMdFileCount; } - setGeminiMdFileCount(count: number): void { + setMemoryFileCount(count: number): void { this.geminiMdFileCount = count; } diff --git a/packages/core/src/config/config.workflow-registration.test.ts b/packages/core/src/config/config.workflow-registration.test.ts index 36091afc0dd..b63f3fe91e9 100644 --- a/packages/core/src/config/config.workflow-registration.test.ts +++ b/packages/core/src/config/config.workflow-registration.test.ts @@ -76,7 +76,7 @@ vi.mock('../ide/ide-client.js', () => ({ }, })); vi.mock('../utils/memory-constants.js', () => ({ - setGeminiMdFilename: vi.fn(), + setMemoryFilename: vi.fn(), })); import * as fs from 'node:fs'; diff --git a/packages/core/src/config/config.workflows.test.ts b/packages/core/src/config/config.workflows.test.ts index 99228299903..766dd432001 100644 --- a/packages/core/src/config/config.workflows.test.ts +++ b/packages/core/src/config/config.workflows.test.ts @@ -76,7 +76,7 @@ vi.mock('../ide/ide-client.js', () => ({ }, })); vi.mock('../utils/memory-constants.js', () => ({ - setGeminiMdFilename: vi.fn(), + setMemoryFilename: vi.fn(), })); import * as fs from 'node:fs'; diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 1a6816768de..854f903b81c 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -445,7 +445,7 @@ export type StreamEvent = | { type: StreamEventType.COMPRESSED; info: ChatCompressionInfo } | { type: StreamEventType.MODEL_FALLBACK; info: ModelFallbackInfo }; -export interface GeminiChatSendOptions { +export interface LlmChatSendOptions { /** Skip only the configured model fallback chain for this request. */ disableModelFallbacks?: boolean; } @@ -2559,7 +2559,7 @@ export class GeminiChat { params: SendMessageParameters, prompt_id: string, goalContext?: GoalTurnPermit, - options?: GeminiChatSendOptions, + options?: LlmChatSendOptions, ): Promise> { const turnGoalContext = goalContext ? { ...goalContext } : undefined; const fullTurnRoute = model.endsWith('\0'); diff --git a/packages/core/src/core/geminiRequest.test.ts b/packages/core/src/core/llm-request.test.ts similarity index 97% rename from packages/core/src/core/geminiRequest.test.ts rename to packages/core/src/core/llm-request.test.ts index 1e5687d0e46..5450ddeac1e 100644 --- a/packages/core/src/core/geminiRequest.test.ts +++ b/packages/core/src/core/llm-request.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect } from 'vitest'; -import { partListUnionToString } from './geminiRequest.js'; +import { partListUnionToString } from './llm-request.js'; import { type Part } from '@google/genai'; describe('partListUnionToString', () => { diff --git a/packages/core/src/core/geminiRequest.ts b/packages/core/src/core/llm-request.ts similarity index 81% rename from packages/core/src/core/geminiRequest.ts rename to packages/core/src/core/llm-request.ts index 73a1873c15e..d36694b535d 100644 --- a/packages/core/src/core/geminiRequest.ts +++ b/packages/core/src/core/llm-request.ts @@ -8,11 +8,11 @@ import type { PartListUnion } from '@google/genai'; import { partToString } from '../utils/partUtils.js'; /** - * Represents a request to be sent to the Gemini API. + * Represents a request to be sent to the LLM API. * For now, it's an alias to PartListUnion as the primary content. * This can be expanded later to include other request parameters. */ -export type GeminiCodeRequest = PartListUnion; +export type LlmCodeRequest = PartListUnion; export function partListUnionToString(value: PartListUnion): string { return partToString(value, { verbose: true }); diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 1c7c593854d..850de7421e5 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -109,7 +109,7 @@ export interface StructuredError { status?: number; } -export interface GeminiErrorEventValue { +export interface LlmErrorEventValue { error: StructuredError; } @@ -119,7 +119,7 @@ export interface SessionTokenLimitExceededValue { message: string; } -export interface GeminiFinishedEventValue { +export interface LlmFinishedEventValue { reason: FinishReason | undefined; usageMetadata: GenerateContentResponseUsageMetadata | undefined; } @@ -345,7 +345,7 @@ export type ServerGeminiUserCancelledEvent = { export type ServerGeminiErrorEvent = { type: GeminiEventType.Error; - value: GeminiErrorEventValue; + value: LlmErrorEventValue; }; export enum CompressionStatus { @@ -417,7 +417,7 @@ export type ServerGeminiSessionTokenLimitExceededEvent = { export type ServerGeminiFinishedEvent = { type: GeminiEventType.Finished; - value: GeminiFinishedEventValue; + value: LlmFinishedEventValue; }; export type ServerGeminiLoopDetectedEvent = { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5453a928a14..fdf86b6e596 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -84,7 +84,7 @@ export { findPlanModeEntryBatchBoundaryIndex, } from './core/plan-mode-entry-policy.js'; export * from './core/geminiChat.js'; -export * from './core/geminiRequest.js'; +export * from './core/llm-request.js'; export * from './core/inlineMediaLimit.js'; export * from './core/insightProtocol.js'; export * from './core/logger.js'; diff --git a/packages/core/src/memory/const.test.ts b/packages/core/src/memory/const.test.ts index 867cfd0b8f3..076e88a4c7e 100644 --- a/packages/core/src/memory/const.test.ts +++ b/packages/core/src/memory/const.test.ts @@ -8,14 +8,14 @@ import { vi, describe, it, expect, beforeEach } from 'vitest'; import { AGENT_CONTEXT_FILENAME, DEFAULT_CONTEXT_FILENAME, - setGeminiMdFilename, - getCurrentGeminiMdFilename, - getAllGeminiMdFilenames, + setMemoryFilename, + getCurrentMemoryFilename, + getAllMemoryFilenames, } from '../utils/memory-constants.js'; import { - setGeminiMdFilename as setToolGeminiMdFilename, - getCurrentGeminiMdFilename as getToolCurrentGeminiMdFilename, - getAllGeminiMdFilenames as getToolAllGeminiMdFilenames, + setMemoryFilename as setToolMemoryFilename, + getCurrentMemoryFilename as getToolCurrentMemoryFilename, + getAllMemoryFilenames as getToolAllMemoryFilenames, } from '../tools/memory-config.js'; // Mock dependencies @@ -30,40 +30,40 @@ vi.mock(import('node:fs/promises'), async (importOriginal) => { vi.mock('os'); -describe('setGeminiMdFilename', () => { +describe('setMemoryFilename', () => { beforeEach(() => { - setGeminiMdFilename([DEFAULT_CONTEXT_FILENAME, AGENT_CONTEXT_FILENAME]); + setMemoryFilename([DEFAULT_CONTEXT_FILENAME, AGENT_CONTEXT_FILENAME]); }); - it('should update currentGeminiMdFilename when a valid new name is provided', () => { + it('should update currentMemoryFilename when a valid new name is provided', () => { const newName = 'CUSTOM_CONTEXT.md'; - setGeminiMdFilename(newName); - expect(getCurrentGeminiMdFilename()).toBe(newName); + setMemoryFilename(newName); + expect(getCurrentMemoryFilename()).toBe(newName); }); - it('should not update currentGeminiMdFilename if the new name is empty or whitespace', () => { - const initialName = getCurrentGeminiMdFilename(); // Get current before trying to change - setGeminiMdFilename(' '); - expect(getCurrentGeminiMdFilename()).toBe(initialName); + it('should not update currentMemoryFilename if the new name is empty or whitespace', () => { + const initialName = getCurrentMemoryFilename(); // Get current before trying to change + setMemoryFilename(' '); + expect(getCurrentMemoryFilename()).toBe(initialName); - setGeminiMdFilename(''); - expect(getCurrentGeminiMdFilename()).toBe(initialName); + setMemoryFilename(''); + expect(getCurrentMemoryFilename()).toBe(initialName); }); it('should handle an array of filenames', () => { const newNames = ['CUSTOM_CONTEXT.md', 'ANOTHER_CONTEXT.md']; - setGeminiMdFilename(newNames); - expect(getCurrentGeminiMdFilename()).toBe('CUSTOM_CONTEXT.md'); - expect(getAllGeminiMdFilenames()).toEqual(newNames); + setMemoryFilename(newNames); + expect(getCurrentMemoryFilename()).toBe('CUSTOM_CONTEXT.md'); + expect(getAllMemoryFilenames()).toEqual(newNames); }); it('shares filename state with the legacy tools memory config entrypoint', () => { - setGeminiMdFilename(['CUSTOM_CONTEXT.md', 'AGENTS.md']); - expect(getToolCurrentGeminiMdFilename()).toBe('CUSTOM_CONTEXT.md'); - expect(getToolAllGeminiMdFilenames()).toEqual(getAllGeminiMdFilenames()); + setMemoryFilename(['CUSTOM_CONTEXT.md', 'AGENTS.md']); + expect(getToolCurrentMemoryFilename()).toBe('CUSTOM_CONTEXT.md'); + expect(getToolAllMemoryFilenames()).toEqual(getAllMemoryFilenames()); - setToolGeminiMdFilename('LEGACY_CONTEXT.md'); - expect(getCurrentGeminiMdFilename()).toBe('LEGACY_CONTEXT.md'); - expect(getAllGeminiMdFilenames()).toEqual(['LEGACY_CONTEXT.md']); + setToolMemoryFilename('LEGACY_CONTEXT.md'); + expect(getCurrentMemoryFilename()).toBe('LEGACY_CONTEXT.md'); + expect(getAllMemoryFilenames()).toEqual(['LEGACY_CONTEXT.md']); }); }); diff --git a/packages/core/src/memory/memoryDiscovery.test.ts b/packages/core/src/memory/memoryDiscovery.test.ts index fcaae1e0b93..50121c4a067 100644 --- a/packages/core/src/memory/memoryDiscovery.test.ts +++ b/packages/core/src/memory/memoryDiscovery.test.ts @@ -13,7 +13,7 @@ import { formatContextFileDisplayPath, } from './memoryDiscovery.js'; import { - setGeminiMdFilename, + setMemoryFilename, DEFAULT_CONTEXT_FILENAME, LOCAL_CONTEXT_FILENAME, } from '../utils/memory-constants.js'; @@ -76,7 +76,7 @@ describe('loadServerHierarchicalMemory', () => { afterEach(async () => { vi.unstubAllEnvs(); // Some tests set this to a different value. - setGeminiMdFilename(DEFAULT_CONTEXT_FILENAME); + setMemoryFilename(DEFAULT_CONTEXT_FILENAME); // Clean up the temporary directory to prevent resource leaks. // Use maxRetries option for robust cleanup without race conditions await fsPromises.rm(testRootDir, { @@ -259,7 +259,7 @@ describe('loadServerHierarchicalMemory', () => { it('should load only the global custom context file if present and filename is changed', async () => { const customFilename = 'CUSTOM_AGENTS.md'; - setGeminiMdFilename(customFilename); + setMemoryFilename(customFilename); const customContextFile = await createTestFile( path.join(homedir, QWEN_DIR, customFilename), @@ -288,7 +288,7 @@ describe('loadServerHierarchicalMemory', () => { it('should load context files by upward traversal with custom filename', async () => { const customFilename = 'PROJECT_CONTEXT.md'; - setGeminiMdFilename(customFilename); + setMemoryFilename(customFilename); const projectContextFile = await createTestFile( path.join(projectRoot, customFilename), @@ -322,7 +322,7 @@ describe('loadServerHierarchicalMemory', () => { it('should load context files from CWD with custom filename (not subdirectories)', async () => { const customFilename = 'LOCAL_CONTEXT.md'; - setGeminiMdFilename(customFilename); + setMemoryFilename(customFilename); await createTestFile( path.join(cwd, 'subdir', customFilename), @@ -1323,7 +1323,7 @@ describe('loadServerHierarchicalMemory', () => { }); it('dedupes when an extension registers the local slot path explicitly', async () => { - // The hierarchical scan iterates `getAllGeminiMdFilenames()` + // The hierarchical scan iterates `getAllMemoryFilenames()` // (QWEN.md / AGENTS.md) and never produces a `QWEN.local.md` path, // so the dedup guard in the slot loader looks unreachable in // production paths. It IS reachable, though, via diff --git a/packages/core/src/memory/memoryDiscovery.ts b/packages/core/src/memory/memoryDiscovery.ts index 5367a2a9fe3..bb67c538784 100644 --- a/packages/core/src/memory/memoryDiscovery.ts +++ b/packages/core/src/memory/memoryDiscovery.ts @@ -9,7 +9,7 @@ import * as fsSync from 'node:fs'; import * as path from 'node:path'; import { homedir } from 'node:os'; import { - getAllGeminiMdFilenames, + getAllMemoryFilenames, LOCAL_CONTEXT_FILENAME, } from '../utils/memory-constants.js'; import type { FileDiscoveryService } from '../services/fileDiscoveryService.js'; @@ -40,7 +40,7 @@ export interface InstructionsLoadedNotification { parentFilePath?: string; } -async function getGeminiMdFilePathsInternal( +async function getMemoryFilePathsInternal( currentWorkingDirectory: string, includeDirectoriesToReadGemini: readonly string[], userHomePath: string, @@ -63,7 +63,7 @@ async function getGeminiMdFilePathsInternal( for (let i = 0; i < dirsArray.length; i += CONCURRENT_LIMIT) { const batch = dirsArray.slice(i, i + CONCURRENT_LIMIT); const batchPromises = batch.map((dir) => - getGeminiMdFilePathsInternalForEachDir( + getMemoryFilePathsInternalForEachDir( dir, userHomePath, fileService, @@ -91,7 +91,7 @@ async function getGeminiMdFilePathsInternal( return Array.from(new Set(paths)); } -async function getGeminiMdFilePathsInternalForEachDir( +async function getMemoryFilePathsInternalForEachDir( dir: string, userHomePath: string, fileService: FileDiscoveryService, @@ -100,7 +100,7 @@ async function getGeminiMdFilePathsInternalForEachDir( implicitDiscoveryEnabled: boolean = true, ): Promise { const allPaths = new Set(); - const geminiMdFilenames = getAllGeminiMdFilenames(); + const geminiMdFilenames = getAllMemoryFilenames(); for (const geminiMdFilename of geminiMdFilenames) { const resolvedHome = path.resolve(userHomePath); @@ -206,14 +206,14 @@ async function getGeminiMdFilePathsInternalForEachDir( const finalPaths = Array.from(allPaths); logger.debug( - `Final ordered ${getAllGeminiMdFilenames()} paths to read: ${JSON.stringify( + `Final ordered ${getAllMemoryFilenames()} paths to read: ${JSON.stringify( finalPaths, )}`, ); return finalPaths; } -async function readGeminiMdFiles( +async function readMemoryFiles( filePaths: string[], importFormat: 'flat' | 'tree' = 'tree', getMemoryType: (filePath: string) => InstructionMemoryType, @@ -288,7 +288,7 @@ async function readGeminiMdFiles( const message = error instanceof Error ? error.message : String(error); logger.warn( - `Warning: Could not read ${getAllGeminiMdFilenames()} file at ${filePath}. Error: ${message}`, + `Warning: Could not read ${getAllMemoryFilenames()} file at ${filePath}. Error: ${message}`, ); } logger.debug(`Failed to read: ${filePath}`); @@ -477,7 +477,7 @@ export async function loadServerHierarchicalMemory( // For the server, homedir() refers to the server process's home. // This is consistent with how MemoryTool already finds the global path. const userHomePath = homedir(); - const filePaths = await getGeminiMdFilePathsInternal( + const filePaths = await getMemoryFilePathsInternal( currentWorkingDirectory, includeDirectoriesToReadGemini, userHomePath, @@ -530,7 +530,7 @@ export async function loadServerHierarchicalMemory( if (filePaths.length > 0) { const loadReason = options.loadReason ?? 'session_start'; - const contentsWithPaths = await readGeminiMdFiles( + const contentsWithPaths = await readMemoryFiles( filePaths, importFormat, createMemoryTypeClassifier( @@ -554,7 +554,7 @@ export async function loadServerHierarchicalMemory( // (/memory count vs announcement list) may differ; aligning them at // the display site is deferred as a follow-up. const memoryFilenames = new Set([ - ...getAllGeminiMdFilenames(), + ...getAllMemoryFilenames(), LOCAL_CONTEXT_FILENAME, ]); const memoryItems = contentsWithPaths.filter((item) => diff --git a/packages/core/src/memory/refresh.test.ts b/packages/core/src/memory/refresh.test.ts index 87b33cad482..18cd74b0f92 100644 --- a/packages/core/src/memory/refresh.test.ts +++ b/packages/core/src/memory/refresh.test.ts @@ -21,7 +21,7 @@ import { import { AGENT_CONTEXT_FILENAME, DEFAULT_CONTEXT_FILENAME, - setGeminiMdFilename, + setMemoryFilename, } from '../utils/memory-constants.js'; import { didWriteManagedMemory, @@ -61,7 +61,7 @@ describe('managed memory refresh helper', () => { vi.mocked(rebuildUserAutoMemoryIndex).mockReset(); vi.mocked(rebuildManagedAutoMemoryIndex).mockResolvedValue(''); vi.mocked(rebuildUserAutoMemoryIndex).mockResolvedValue(''); - setGeminiMdFilename([DEFAULT_CONTEXT_FILENAME, AGENT_CONTEXT_FILENAME]); + setMemoryFilename([DEFAULT_CONTEXT_FILENAME, AGENT_CONTEXT_FILENAME]); }); afterEach(async () => { @@ -259,7 +259,7 @@ describe('managed memory refresh helper', () => { }); it('detects configured project context file writes', () => { - setGeminiMdFilename('PROJECT_CONTEXT.md'); + setMemoryFilename('PROJECT_CONTEXT.md'); expect( didWriteProjectContextFile( diff --git a/packages/core/src/memory/refresh.ts b/packages/core/src/memory/refresh.ts index a28b0a03b84..568c395e71a 100644 --- a/packages/core/src/memory/refresh.ts +++ b/packages/core/src/memory/refresh.ts @@ -8,7 +8,7 @@ import * as path from 'node:path'; import type { Config } from '../config/config.js'; import { ToolNames, canonicalToolName } from '../tools/tool-names.js'; import { createDebugLogger } from '../utils/debugLogger.js'; -import { getAllGeminiMdFilenames } from '../utils/memory-constants.js'; +import { getAllMemoryFilenames } from '../utils/memory-constants.js'; import { isAllowedMemoryPath } from './memory-scoped-agent-config.js'; import { rebuildManagedAutoMemoryIndex, @@ -93,7 +93,7 @@ export function didWriteProjectContextFile( projectRoot: string, ): boolean { const contextFilePaths = new Set( - getAllGeminiMdFilenames() + getAllMemoryFilenames() .map((name) => name.trim()) .filter((name) => name.length > 0) .map((name) => path.resolve(projectRoot, name)), diff --git a/packages/core/src/memory/writeContextFile.test.ts b/packages/core/src/memory/writeContextFile.test.ts index 7d08b8a8e77..68b549a114b 100644 --- a/packages/core/src/memory/writeContextFile.test.ts +++ b/packages/core/src/memory/writeContextFile.test.ts @@ -13,7 +13,7 @@ import { AGENT_CONTEXT_FILENAME, DEFAULT_CONTEXT_FILENAME, MEMORY_SECTION_HEADER, - setGeminiMdFilename, + setMemoryFilename, } from '../utils/memory-constants.js'; import { writeWorkspaceContextFile } from './writeContextFile.js'; @@ -385,15 +385,15 @@ describe('writeWorkspaceContextFile', () => { await expect(fs.access(nested)).rejects.toMatchObject({ code: 'ENOENT' }); }); - it('honors setGeminiMdFilename overrides so POST targets the same file GET surfaces', async () => { - // Round-trip the `setGeminiMdFilename` override: with the prior + it('honors setMemoryFilename overrides so POST targets the same file GET surfaces', async () => { + // Round-trip the `setMemoryFilename` override: with the prior // `DEFAULT_CONTEXT_FILENAME` hard-code, a deployment that switched // the context filename to `AGENTS.md` saw GET list the new file // but POST keep writing to `QWEN.md`. The fix routes - // `resolveContextFilePath` through `getCurrentGeminiMdFilename()` + // `resolveContextFilePath` through `getCurrentMemoryFilename()` // so both surfaces agree. try { - setGeminiMdFilename(AGENT_CONTEXT_FILENAME); + setMemoryFilename(AGENT_CONTEXT_FILENAME); const result = await writeWorkspaceContextFile({ scope: 'workspace', mode: 'append', @@ -411,7 +411,7 @@ describe('writeWorkspaceContextFile', () => { fs.access(path.join(workspace, DEFAULT_CONTEXT_FILENAME)), ).rejects.toMatchObject({ code: 'ENOENT' }); } finally { - setGeminiMdFilename(DEFAULT_CONTEXT_FILENAME); + setMemoryFilename(DEFAULT_CONTEXT_FILENAME); } }); }); diff --git a/packages/core/src/memory/writeContextFile.ts b/packages/core/src/memory/writeContextFile.ts index bc1537774d7..cab329c3993 100644 --- a/packages/core/src/memory/writeContextFile.ts +++ b/packages/core/src/memory/writeContextFile.ts @@ -14,7 +14,7 @@ import { } from 'async-mutex'; import { Storage } from '../config/storage.js'; import { - getCurrentGeminiMdFilename, + getCurrentMemoryFilename, MEMORY_SECTION_HEADER, } from '../utils/memory-constants.js'; @@ -232,15 +232,15 @@ function resolveContextFilePath( scope: WriteContextFileScope, projectRoot: string, ): string { - // Honor `setGeminiMdFilename()` overrides so POST writes to the same + // Honor `setMemoryFilename()` overrides so POST writes to the same // file GET surfaces. With the prior `DEFAULT_CONTEXT_FILENAME` hard- // code, a deployment that switched the context filename to // `AGENTS.md` would have GET listing the new file while POST kept // appending to a stale `QWEN.md` — clients then observed "I just // wrote content but it's missing from /workspace/memory". Mirrors the - // discovery path's `getAllGeminiMdFilenames()` usage in + // discovery path's `getAllMemoryFilenames()` usage in // `workspace-memory.ts:collectWorkspaceMemoryStatus`. - const filename = getCurrentGeminiMdFilename(); + const filename = getCurrentMemoryFilename(); if (scope === 'workspace') { return path.join(projectRoot, filename); } diff --git a/packages/core/src/permissions/autoMode.test.ts b/packages/core/src/permissions/autoMode.test.ts index d5aeb03acdb..389c2458f4a 100644 --- a/packages/core/src/permissions/autoMode.test.ts +++ b/packages/core/src/permissions/autoMode.test.ts @@ -29,7 +29,7 @@ import { ApprovalMode } from '../config/config.js'; import { ToolNames } from '../tools/tool-names.js'; import type { Config } from '../config/config.js'; import type { PermissionCheckContext } from './types.js'; -import { setGeminiMdFilename } from '../utils/memory-constants.js'; +import { setMemoryFilename } from '../utils/memory-constants.js'; // ─── SAFE_TOOL_ALLOWLIST contents (frozen) ─────────────────────────────── @@ -212,7 +212,7 @@ describe('isAutoModeProtectedWritePath', () => { }); it('matches configured context filenames', () => { - setGeminiMdFilename(['CUSTOM_AGENTS.md', 'docs/TEAM_CONTEXT.md']); + setMemoryFilename(['CUSTOM_AGENTS.md', 'docs/TEAM_CONTEXT.md']); try { const protectedPaths = [ '/repo/CUSTOM_AGENTS.md', @@ -224,7 +224,7 @@ describe('isAutoModeProtectedWritePath', () => { expect(isAutoModeProtectedWritePath(filePath)).toBe(true); } } finally { - setGeminiMdFilename(['QWEN.md', 'AGENTS.md']); + setMemoryFilename(['QWEN.md', 'AGENTS.md']); } }); diff --git a/packages/core/src/permissions/autoMode.ts b/packages/core/src/permissions/autoMode.ts index fdb68567f2c..6a51c1dbae2 100644 --- a/packages/core/src/permissions/autoMode.ts +++ b/packages/core/src/permissions/autoMode.ts @@ -22,7 +22,7 @@ import path from 'node:path'; import type { Content } from '@google/genai'; import { ApprovalMode, type Config } from '../config/config.js'; import { - getAllGeminiMdFilenames, + getAllMemoryFilenames, LOCAL_CONTEXT_FILENAME, } from '../utils/memory-constants.js'; import type { PermissionDeniedReason } from '../hooks/types.js'; @@ -172,7 +172,7 @@ function trimPathSlashes(filePath: string): string { } function matchesConfiguredContextFile(normalizedPath: string): boolean { - return [...getAllGeminiMdFilenames(), LOCAL_CONTEXT_FILENAME].some( + return [...getAllMemoryFilenames(), LOCAL_CONTEXT_FILENAME].some( (filename) => { const normalizedFilename = trimPathSlashes( normalizePathForAutoModePattern(filename), diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index 8957aa7469b..26e35ee93a5 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -83,8 +83,8 @@ describe('EditTool', () => { getUserAgent: () => 'test-agent', getUserMemory: () => '', setUserMemory: vi.fn(), - getGeminiMdFileCount: () => 0, - setGeminiMdFileCount: vi.fn(), + getMemoryFileCount: () => 0, + setMemoryFileCount: vi.fn(), getToolRegistry: () => ({}) as any, // Minimal mock for ToolRegistry getDefaultFileEncoding: vi.fn().mockReturnValue('utf-8'), getFileReadCache: () => fileReadCache, diff --git a/packages/core/src/tools/glob.test.ts b/packages/core/src/tools/glob.test.ts index cc4c8239182..de452bcef83 100644 --- a/packages/core/src/tools/glob.test.ts +++ b/packages/core/src/tools/glob.test.ts @@ -6,7 +6,7 @@ import type { GlobToolParams, GlobPath } from './glob.js'; import { GlobTool, sortFileEntries } from './glob.js'; -import { partListUnionToString } from '../core/geminiRequest.js'; +import { partListUnionToString } from '../core/llm-request.js'; import path from 'node:path'; import fs from 'node:fs/promises'; import os from 'node:os'; diff --git a/packages/core/src/tools/memory-config.ts b/packages/core/src/tools/memory-config.ts index 6e060453dfc..0ba210912bb 100644 --- a/packages/core/src/tools/memory-config.ts +++ b/packages/core/src/tools/memory-config.ts @@ -12,8 +12,8 @@ export { AGENT_CONTEXT_FILENAME, DEFAULT_CONTEXT_FILENAME, - getAllGeminiMdFilenames, - getCurrentGeminiMdFilename, + getAllMemoryFilenames, + getCurrentMemoryFilename, MEMORY_SECTION_HEADER, - setGeminiMdFilename, + setMemoryFilename, } from '../utils/memory-constants.js'; diff --git a/packages/core/src/tools/notebook-edit.test.ts b/packages/core/src/tools/notebook-edit.test.ts index 40de455a46b..880a3a6814b 100644 --- a/packages/core/src/tools/notebook-edit.test.ts +++ b/packages/core/src/tools/notebook-edit.test.ts @@ -65,8 +65,8 @@ describe('NotebookEditTool', () => { getUserAgent: () => 'test-agent', getUserMemory: () => '', setUserMemory: vi.fn(), - getGeminiMdFileCount: () => 0, - setGeminiMdFileCount: vi.fn(), + getMemoryFileCount: () => 0, + setMemoryFileCount: vi.fn(), getToolRegistry: () => ({}) as never, } as unknown as Config; tool = new NotebookEditTool(config); diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index 698c7933723..c33475dfb37 100644 --- a/packages/core/src/tools/write-file.test.ts +++ b/packages/core/src/tools/write-file.test.ts @@ -68,8 +68,8 @@ const mockConfigInternal = { getUserAgent: () => 'test-agent', getUserMemory: () => '', setUserMemory: vi.fn(), - getGeminiMdFileCount: () => 0, - setGeminiMdFileCount: vi.fn(), + getMemoryFileCount: () => 0, + setMemoryFileCount: vi.fn(), getToolRegistry: () => ({ registerTool: vi.fn(), diff --git a/packages/core/src/utils/ignorePatterns.test.ts b/packages/core/src/utils/ignorePatterns.test.ts index 8b539e342e0..61d97bcafbe 100644 --- a/packages/core/src/utils/ignorePatterns.test.ts +++ b/packages/core/src/utils/ignorePatterns.test.ts @@ -14,7 +14,7 @@ import type { Config } from '../config/config.js'; // Mock the memoryTool module vi.mock('./memory-constants.js', () => ({ - getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md', 'AGENTS.md']), + getAllMemoryFilenames: vi.fn(() => ['GEMINI.md', 'AGENTS.md']), })); describe('FileExclusions', () => { diff --git a/packages/core/src/utils/ignorePatterns.ts b/packages/core/src/utils/ignorePatterns.ts index 6d64dd474ae..67fbb24bee9 100644 --- a/packages/core/src/utils/ignorePatterns.ts +++ b/packages/core/src/utils/ignorePatterns.ts @@ -6,7 +6,7 @@ import path from 'node:path'; import type { Config } from '../config/config.js'; -import { getAllGeminiMdFilenames } from './memory-constants.js'; +import { getAllMemoryFilenames } from './memory-constants.js'; /** * Common ignore patterns used across multiple tools for basic exclusions. @@ -160,7 +160,7 @@ export class FileExclusions { // Add dynamic patterns (like context filenames) if (includeDynamicPatterns) { - for (const filename of getAllGeminiMdFilenames()) { + for (const filename of getAllMemoryFilenames()) { patterns.push(`**/${filename}`); } } diff --git a/packages/core/src/utils/memory-constants.ts b/packages/core/src/utils/memory-constants.ts index 134f314a677..d286510fbe2 100644 --- a/packages/core/src/utils/memory-constants.ts +++ b/packages/core/src/utils/memory-constants.ts @@ -29,25 +29,25 @@ export const LOCAL_CONTEXT_FILENAME = 'QWEN.local.md'; export const MEMORY_SECTION_HEADER = '## Qwen Added Memories'; // This variable will hold the currently configured filename for context files. -// It defaults to include both QWEN.md and AGENTS.md but can be overridden by setGeminiMdFilename. +// It defaults to include both QWEN.md and AGENTS.md but can be overridden by setMemoryFilename. // QWEN.md is first to maintain backward compatibility (used by /init command tool). -let currentGeminiMdFilename: string | string[] = [ +let currentMemoryFilename: string | string[] = [ DEFAULT_CONTEXT_FILENAME, AGENT_CONTEXT_FILENAME, ]; -export function setGeminiMdFilename(newFilename: string | string[]): void { +export function setMemoryFilename(newFilename: string | string[]): void { if (Array.isArray(newFilename)) { if (newFilename.length > 0) { - currentGeminiMdFilename = newFilename.map((name) => name.trim()); + currentMemoryFilename = newFilename.map((name) => name.trim()); } } else if (newFilename && newFilename.trim() !== '') { - currentGeminiMdFilename = newFilename.trim(); + currentMemoryFilename = newFilename.trim(); } } -export function getCurrentGeminiMdFilename(): string { - if (Array.isArray(currentGeminiMdFilename)) { +export function getCurrentMemoryFilename(): string { + if (Array.isArray(currentMemoryFilename)) { // (qwen-latest critical, addresses divergence // with daemon's `extractContextFilename`): skip empty / whitespace // entries so callers that pass `[' ', 'AGENTS.md']` get @@ -56,7 +56,7 @@ export function getCurrentGeminiMdFilename(): string { // process-global picker disagreed on the same input — daemon // parent would write `AGENTS.md` while the ACP child would read // `''`, leaving the init'd file orphaned. - for (const entry of currentGeminiMdFilename) { + for (const entry of currentMemoryFilename) { if (typeof entry === 'string' && entry.trim() !== '') { return entry.trim(); } @@ -65,12 +65,12 @@ export function getCurrentGeminiMdFilename(): string { // than return `undefined` (callers expect a non-empty string). return DEFAULT_CONTEXT_FILENAME; } - return currentGeminiMdFilename; + return currentMemoryFilename; } -export function getAllGeminiMdFilenames(): string[] { - if (Array.isArray(currentGeminiMdFilename)) { - return currentGeminiMdFilename; +export function getAllMemoryFilenames(): string[] { + if (Array.isArray(currentMemoryFilename)) { + return currentMemoryFilename; } - return [currentGeminiMdFilename]; + return [currentMemoryFilename]; } From 305123e34350a464b6464b6a0108db22758b3177 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 24 Aug 2026 18:10:54 +0800 Subject: [PATCH 02/15] fix(cli): resolve rename build failure --- docs/developers/daemon/02-serve-runtime.md | 4 +- docs/developers/daemon/03-acp-bridge.md | 12 ++--- docs/developers/daemon/17-configuration.md | 4 +- .../daemon/20-quickstart-operations.md | 2 +- docs/developers/qwen-serve-protocol.md | 2 +- eslint.legacy-filenames.mjs | 1 - packages/cli/src/core/initializer.test.ts | 2 +- packages/cli/src/core/initializer.ts | 4 +- packages/cli/src/gemini.test.tsx | 46 +++++++++---------- packages/cli/src/ui/AppContainer.test.tsx | 2 +- packages/cli/src/ui/AppContainer.tsx | 8 ++-- .../cli/src/ui/commands/initCommand.test.ts | 8 ++-- .../cli/src/ui/components/Composer.test.tsx | 2 +- .../components/ContextSummaryDisplay.test.tsx | 4 +- .../ui/components/ContextSummaryDisplay.tsx | 18 ++++---- .../cli/src/ui/components/Footer.test.tsx | 2 +- .../src/ui/components/MainContent.test.tsx | 2 +- .../src/ui/components/RespondingSpinner.tsx | 16 +++---- .../cli/src/ui/contexts/UIStateContext.tsx | 2 +- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 2 +- .../cli/src/ui/startInteractiveUI.test.tsx | 2 +- packages/core/src/config/config.ts | 10 ++-- packages/core/src/memory/memoryDiscovery.ts | 20 ++++---- packages/core/src/tools/tool-registry.test.ts | 2 +- packages/core/src/tools/tool-search.test.ts | 2 +- 25 files changed, 88 insertions(+), 91 deletions(-) diff --git a/docs/developers/daemon/02-serve-runtime.md b/docs/developers/daemon/02-serve-runtime.md index 49bcbd76613..468084c03d7 100644 --- a/docs/developers/daemon/02-serve-runtime.md +++ b/docs/developers/daemon/02-serve-runtime.md @@ -110,7 +110,7 @@ Calling `createServeApp` directly still returns only an `Application`. An embedd | Upstream used by `serve/` | Downstream using `serve/` | | ----------------------------------------------------------------------------------------------- | ----------------------------------------- | | `@qwen-code/acp-bridge`: bridge, event bus, status types | The `qwen` CLI `serve` subcommand handler | -| `packages/core`: `loadSettings`, `getCurrentGeminiMdFilename`, `Config`, `WorkspaceContext` | Direct embedders, tests | +| `packages/core`: `loadSettings`, `getCurrentMemoryFilename`, `Config`, `WorkspaceContext` | Direct embedders, tests | | ACP SDK (`@agentclientprotocol/sdk`): `PROTOCOL_VERSION`, `ClientSideConnection` through bridge | | | Express + body-parser, `node:crypto`, `node:fs`, `node:path` | | @@ -136,7 +136,7 @@ Calling `createServeApp` directly still returns only an `Application`. An embedd | Flags | `--session-reap-interval-ms`, `--session-idle-timeout-ms` | Disconnected-session reaping control. | | Flags | `--rate-limit*` | Per-tier HTTP rate limit. | | `settings.json` | `policy.permissionStrategy`, `policy.consensusQuorum` | `MultiClientPermissionMediator` policy and quorum. | -| `settings.json` | `context.fileName` | `getCurrentGeminiMdFilename` override for the bridge. | +| `settings.json` | `context.fileName` | `getCurrentMemoryFilename` override for the bridge. | See [`17-configuration.md`](./17-configuration.md) for the merged reference. diff --git a/docs/developers/daemon/03-acp-bridge.md b/docs/developers/daemon/03-acp-bridge.md index 5915e1ba290..c86ddc8b221 100644 --- a/docs/developers/daemon/03-acp-bridge.md +++ b/docs/developers/daemon/03-acp-bridge.md @@ -184,11 +184,11 @@ sequenceDiagram ## Dependencies -| Upstream | Downstream | -| -------------------------------------------------------------------------------------------- | ---------------------------------------------- | -| `@agentclientprotocol/sdk` — `ClientSideConnection`, `PROTOCOL_VERSION`, ACP types | `packages/cli/src/serve/` (the daemon) | -| `@qwen-code/qwen-code-core` — `ApprovalMode`, `TrustGateError`, `getCurrentGeminiMdFilename` | `packages/channels/base/` (planned, F4) | -| `node:crypto`, `node:fs`, `node:path` | `packages/vscode-ide-companion/` (planned, F4) | +| Upstream | Downstream | +| ------------------------------------------------------------------------------------------ | ---------------------------------------------- | +| `@agentclientprotocol/sdk` — `ClientSideConnection`, `PROTOCOL_VERSION`, ACP types | `packages/cli/src/serve/` (the daemon) | +| `@qwen-code/qwen-code-core` — `ApprovalMode`, `TrustGateError`, `getCurrentMemoryFilename` | `packages/channels/base/` (planned, F4) | +| `node:crypto`, `node:fs`, `node:path` | `packages/vscode-ide-companion/` (planned, F4) | ## Configuration @@ -208,7 +208,7 @@ sequenceDiagram | `childEnvOverrides` | `{}` | Per-handle env additions / scrubs for the ACP child. | | `externalToolGuard` | (none) | Optional handler for the private child-to-parent pre-execution decision. The bridge accepts it only from the owning channel for the currently active Prompt. | | `persistApprovalMode`, `persistDisabledTools` | — | Settings-write hooks for the Wave 4 mutation routes. | -| `contextFilename` | from `settings.json`'s `context.fileName` | Overrides `getCurrentGeminiMdFilename`. | +| `contextFilename` | from `settings.json`'s `context.fileName` | Overrides `getCurrentMemoryFilename`. | | `statusProvider` | (none) | Daemon-host preflight cells (`DaemonStatusProvider`). | | `delegateReadTextFileToClient` | `true` | Set `false` only for same-host runtimes so every child `FileSystemService.readTextFile` consumer uses the regular CLI filesystem service. | | `fileSystem` | (none) | `BridgeFileSystem` adapter for ACP `readTextFile` / `writeTextFile`. | diff --git a/docs/developers/daemon/17-configuration.md b/docs/developers/daemon/17-configuration.md index a1d0762893c..4a63bbff534 100644 --- a/docs/developers/daemon/17-configuration.md +++ b/docs/developers/daemon/17-configuration.md @@ -105,7 +105,7 @@ The daemon constructs each workspace runtime from that workspace's merged settin | --------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `policy.permissionStrategy` | `'first-responder' \| 'designated' \| 'consensus' \| 'local-only'` | Sets `BridgeOptions.permissionPolicy`; the active value appears in `/capabilities` as `policy.permission`. **Boot validates** through `validatePolicyConfig()` against `SERVE_CAPABILITY_REGISTRY.permission_mediation.modes`. Unknown literals throw `InvalidPolicyConfigError` and fail boot explicitly. | | `policy.consensusQuorum` | positive integer | N for the `consensus` policy. **Default** is `floor(M/2) + 1` over `votersAtIssue.size` (M=2 means unanimous; larger even M means more than half). If set under a non-consensus policy, it is ignored and boot prints a stderr warning. Non-positive integers throw `InvalidPolicyConfigError`. See [`04-permission-mediation.md`](./04-permission-mediation.md). | -| `context.fileName` | string | Overrides `getCurrentGeminiMdFilename()` through `BridgeOptions.contextFilename`. | +| `context.fileName` | string | Overrides `getCurrentMemoryFilename()` through `BridgeOptions.contextFilename`. | | `tools.disabled` | string[] | Tools disabled for the next ACP child spawn. Normalized through `normalizeDisabledToolList()` (`packages/cli/src/config/normalizeDisabledTools.ts`): non-array becomes `[]`, non-string entries are skipped, whitespace is trimmed, empty entries are dropped, and duplicates are removed while preserving first occurrence. Boot and `restartMcpServer` settings refresh both run through this function. `ToolRegistry.has(name)` is exact and case-sensitive. `POST /workspace/tools/:name/enable` and `tool_toggled` update this key. | | `tools.approvalMode` | `'default' \| 'auto' \| ...` | Default session approval mode; `POST /session/:id/approval-mode` writes here when `persist: true`. | | `telemetry` | object | OTel config. Keys include `enabled`, `otlpEndpoint`, `otlpProtocol`, `otlpTracesEndpoint`, `otlpLogsEndpoint`, `otlpMetricsEndpoint`, `target`, `outfile`, `userId`, `includeSensitiveSpanAttributes`, `sensitiveSpanAttributeMaxLength`, `resourceAttributes`, and `metrics.includeSessionId`. `resolveTelemetrySettings()` reads it at boot and initializes `initializeTelemetry()`. `userId` is process-wide and must not be configured as end-user identity when the daemon serves multiple users. | @@ -149,7 +149,7 @@ The daemon constructs each workspace runtime from that workspace's merged settin | `statusProvider` | Daemon-host preflight cells. | | `childEnvOverrides` | Per-handle environment additions or removals. | | `externalToolGuard` | Optional daemon-side handler for the private child-to-parent prepare RPC. The bridge validates channel ownership and the active Prompt before and after it calls the handler. | -| `contextFilename` | Overrides `getCurrentGeminiMdFilename()`. | +| `contextFilename` | Overrides `getCurrentMemoryFilename()`. | | `channelIdleTimeoutMs` | How long to keep the ACP child alive after the last session closes, in ms; default `0`. | ## Important defaults diff --git a/docs/developers/daemon/20-quickstart-operations.md b/docs/developers/daemon/20-quickstart-operations.md index 5ba5f5241e9..8dea0cfcb0a 100644 --- a/docs/developers/daemon/20-quickstart-operations.md +++ b/docs/developers/daemon/20-quickstart-operations.md @@ -137,7 +137,7 @@ Boot calls `loadSettings(boundWorkspace)` once: | --------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `policy.permissionStrategy` | `'first-responder' \| 'designated' \| 'consensus' \| 'local-only'` | Sets `BridgeOptions.permissionPolicy`. **Boot validates with `validatePolicyConfig`**; unknown values throw `InvalidPolicyConfigError` instead of falling back silently. | | `policy.consensusQuorum` | positive integer | N for the `consensus` policy. Default is `floor(M/2)+1`. If set under a non-consensus policy, it is ignored and boot logs a stderr warning. | -| `context.fileName` | string | Overrides `getCurrentGeminiMdFilename()` and controls which file `POST /workspace/init` writes. | +| `context.fileName` | string | Overrides `getCurrentMemoryFilename()` and controls which file `POST /workspace/init` writes. | | `tools.disabled` | string[] | Normalized through `normalizeDisabledToolList()` (trim, drop empty entries, dedupe) before affecting the next ACP child spawn. | | `tools.approvalMode` | string | Default session approval mode. | | `telemetry` | object | OTel configuration: `enabled`, `otlpEndpoint`, `otlpProtocol`, per-signal endpoints, and more. See [`17-configuration.md`](./17-configuration.md). | diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index ee4f6bfbf81..e14eb8e4f5e 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -2899,7 +2899,7 @@ Target errors use `skill_not_found`, `skill_not_toggleable`, or `skill_inactive_ Capability tag: `workspace_init`. Pure file IO — no ACP roundtrip, **no LLM invocation**. -Scaffold an empty `QWEN.md` (or whatever `getCurrentGeminiMdFilename()` returns under `--memory-file-name` overrides) at the daemon's primary workspace root. Mechanical only — for AI-driven content fill, follow up with `POST /session/:id/prompt`. +Scaffold an empty `QWEN.md` (or whatever `getCurrentMemoryFilename()` returns under `--memory-file-name` overrides) at the daemon's primary workspace root. Mechanical only — for AI-driven content fill, follow up with `POST /session/:id/prompt`. Default refuses to overwrite when the target file exists with non-whitespace content. Whitespace-only files are treated as absent (matches the local `/init` slash command). diff --git a/eslint.legacy-filenames.mjs b/eslint.legacy-filenames.mjs index a2839e04beb..b1a74313992 100644 --- a/eslint.legacy-filenames.mjs +++ b/eslint.legacy-filenames.mjs @@ -134,7 +134,6 @@ export const legacyFilenames = [ 'functionHookRunner', 'geminiChat', 'geminiContentGenerator', - 'geminiRequest', 'generateContentResponseUtilities', 'generatedFiles', 'getFolderStructure', diff --git a/packages/cli/src/core/initializer.test.ts b/packages/cli/src/core/initializer.test.ts index 84c33fe8460..74aa2148aee 100644 --- a/packages/cli/src/core/initializer.test.ts +++ b/packages/cli/src/core/initializer.test.ts @@ -96,7 +96,7 @@ describe('initializeApp', () => { expect(result.authError).toBeNull(); expect(result.themeError).toBeNull(); - expect(result.geminiMdFileCount).toBe(0); + expect(result.memoryFileCount).toBe(0); }); it('should return authError when auth fails', async () => { diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts index a67626aa513..58edb1d2269 100644 --- a/packages/cli/src/core/initializer.ts +++ b/packages/cli/src/core/initializer.ts @@ -20,7 +20,7 @@ export interface InitializationResult { authError: string | null; themeError: string | null; shouldOpenAuthDialog: boolean; - geminiMdFileCount: number; + memoryFileCount: number; } export interface InitializeAppOptions { @@ -82,6 +82,6 @@ export async function initializeApp( authError, themeError, shouldOpenAuthDialog, - geminiMdFileCount: config.getMemoryFileCount(), + memoryFileCount: config.getMemoryFileCount(), }; } diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 85cc346974a..7c68c619187 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -195,7 +195,7 @@ vi.mock('./core/initializer.js', () => ({ authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }), })); @@ -1078,7 +1078,7 @@ describe('gemini.tsx main function', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }); vi.spyOn(startupWarningsModule, 'getStartupWarnings').mockResolvedValue([]); vi.spyOn( @@ -1477,7 +1477,7 @@ describe('gemini.tsx main function', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }); vi.spyOn(startupWarningsModule, 'getStartupWarnings').mockResolvedValue([]); vi.spyOn( @@ -1705,7 +1705,7 @@ describe('gemini.tsx main function kitty protocol', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }); vi.mocked(loadCliConfig).mockResolvedValue({ ...sessionRegistryConfigStub, @@ -1832,7 +1832,7 @@ describe('gemini.tsx main function kitty protocol', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }); vi.mocked(loadCliConfig).mockResolvedValue({ ...sessionRegistryConfigStub, @@ -1957,7 +1957,7 @@ describe('gemini.tsx main function kitty protocol', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }); vi.mocked(loadCliConfig).mockResolvedValue({ ...sessionRegistryConfigStub, @@ -2081,7 +2081,7 @@ describe('gemini.tsx main function kitty protocol', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }); vi.mocked(loadCliConfig).mockResolvedValue({ ...sessionRegistryConfigStub, @@ -2736,7 +2736,7 @@ describe('startInteractiveUI', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }; await startInteractiveUI( @@ -2781,7 +2781,7 @@ describe('startInteractiveUI', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }; await startInteractiveUI( @@ -2809,7 +2809,7 @@ describe('startInteractiveUI', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }; await startInteractiveUI( @@ -2833,7 +2833,7 @@ describe('startInteractiveUI', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }; await startInteractiveUI( @@ -2857,7 +2857,7 @@ describe('startInteractiveUI', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }; await startInteractiveUI( @@ -2884,7 +2884,7 @@ describe('startInteractiveUI', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }; await startInteractiveUI( @@ -2924,7 +2924,7 @@ describe('startInteractiveUI', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }; await startInteractiveUI( @@ -2950,7 +2950,7 @@ describe('startInteractiveUI', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }; await startInteractiveUI( @@ -2992,7 +2992,7 @@ describe('startInteractiveUI', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }; await startInteractiveUI( @@ -3019,7 +3019,7 @@ describe('startInteractiveUI', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }; await startInteractiveUI( @@ -3060,7 +3060,7 @@ describe('startInteractiveUI', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }, ); @@ -3094,7 +3094,7 @@ describe('startInteractiveUI', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }, ); @@ -3127,7 +3127,7 @@ describe('startInteractiveUI', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }, ); @@ -3161,7 +3161,7 @@ describe('startInteractiveUI', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }, ); @@ -3345,7 +3345,7 @@ describe('startInteractiveUI', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }, ); @@ -3380,7 +3380,7 @@ describe('startInteractiveUI', () => { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, }, ); await vi.advanceTimersByTimeAsync(30_000); diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 192b96d09a9..97c207df81b 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -486,7 +486,7 @@ describe('AppContainer State Management', () => { themeError: null, authError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, } as InitializationResult; }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 87e22617d96..ae55e3517a5 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -684,8 +684,8 @@ export const AppContainer = (props: AppContainerProps) => { const [isProcessing, setIsProcessing] = useState(false); const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false); - const [geminiMdFileCount, setMemoryFileCount] = useState( - initializationResult.geminiMdFileCount, + const [memoryFileCount, setMemoryFileCount] = useState( + initializationResult.memoryFileCount, ); const [shellModeActive, setShellModeActive] = useState(false); const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] = @@ -4510,7 +4510,7 @@ export const AppContainer = (props: AppContainerProps) => { settingInputRequests, pluginChoiceRequests, loopDetectionConfirmationRequest, - geminiMdFileCount, + memoryFileCount, streamingState, initError, pendingGeminiHistoryItems, @@ -4656,7 +4656,7 @@ export const AppContainer = (props: AppContainerProps) => { settingInputRequests, pluginChoiceRequests, loopDetectionConfirmationRequest, - geminiMdFileCount, + memoryFileCount, streamingState, initError, pendingGeminiHistoryItems, diff --git a/packages/cli/src/ui/commands/initCommand.test.ts b/packages/cli/src/ui/commands/initCommand.test.ts index 187c78ec790..72017744408 100644 --- a/packages/cli/src/ui/commands/initCommand.test.ts +++ b/packages/cli/src/ui/commands/initCommand.test.ts @@ -36,7 +36,7 @@ describe('initCommand', () => { let mockContext: CommandContext; const targetDir = '/test/dir'; const DEFAULT_CONTEXT_FILENAME = 'QWEN.md'; - const geminiMdPath = path.join(targetDir, DEFAULT_CONTEXT_FILENAME); + const memoryFilePath = path.join(targetDir, DEFAULT_CONTEXT_FILENAME); beforeEach(() => { // Create a fresh mock context for each test @@ -99,7 +99,7 @@ describe('initCommand', () => { const result = await initCommand.action!(mockContext, ''); // Assert: Check that writeFileSync was called correctly - expect(fs.writeFileSync).toHaveBeenCalledWith(geminiMdPath, '', 'utf8'); + expect(fs.writeFileSync).toHaveBeenCalledWith(memoryFilePath, '', 'utf8'); // Assert: Check that an informational message was added to the UI expect(mockContext.ui.addItem).toHaveBeenCalledWith( @@ -127,7 +127,7 @@ describe('initCommand', () => { const result = await initCommand.action!(mockContext, ''); - expect(fs.writeFileSync).toHaveBeenCalledWith(geminiMdPath, '', 'utf8'); + expect(fs.writeFileSync).toHaveBeenCalledWith(memoryFilePath, '', 'utf8'); expect(result).toEqual( expect.objectContaining({ type: 'submit_prompt', @@ -145,7 +145,7 @@ describe('initCommand', () => { const result = await initCommand.action!(mockContext, ''); // Assert: Check that writeFileSync was called correctly - expect(fs.writeFileSync).toHaveBeenCalledWith(geminiMdPath, '', 'utf8'); + expect(fs.writeFileSync).toHaveBeenCalledWith(memoryFilePath, '', 'utf8'); // Assert: Check that an informational message was added to the UI expect(mockContext.ui.addItem).toHaveBeenCalledWith( diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 17da21b3a0b..db76376d343 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -132,7 +132,7 @@ const createMockUIState = (overrides: Partial = {}): UIState => ctrlDPressedOnce: false, showEscapePrompt: false, ideContextState: null, - geminiMdFileCount: 0, + memoryFileCount: 0, showToolDescriptions: false, sessionStats: { lastPromptTokenCount: 0, diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx index 4b3bab5db5a..f2e003d037d 100644 --- a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx @@ -26,7 +26,7 @@ const renderWithWidth = ( describe('', () => { const baseProps = { - geminiMdFileCount: 1, + memoryFileCount: 1, contextFileNames: ['QWEN.md'], mcpServers: { 'test-server': { command: 'test' } }, showToolDescriptions: false, @@ -74,7 +74,7 @@ describe('', () => { it('should not render empty parts', () => { const props = { ...baseProps, - geminiMdFileCount: 0, + memoryFileCount: 0, mcpServers: {}, }; const { lastFrame } = renderWithWidth(60, props); diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx index 808c0ac7866..ca48dc653d2 100644 --- a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx @@ -16,7 +16,7 @@ import { isNarrowWidth } from '../utils/isNarrowWidth.js'; import { t } from '../../i18n/index.js'; interface ContextSummaryDisplayProps { - geminiMdFileCount: number; + memoryFileCount: number; contextFileNames: string[]; mcpServers?: Record; blockedMcpServers?: Array<{ name: string; extensionName: string }>; @@ -25,7 +25,7 @@ interface ContextSummaryDisplayProps { } export const ContextSummaryDisplay: React.FC = ({ - geminiMdFileCount, + memoryFileCount, contextFileNames, mcpServers, blockedMcpServers, @@ -39,7 +39,7 @@ export const ContextSummaryDisplay: React.FC = ({ const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0; if ( - geminiMdFileCount === 0 && + memoryFileCount === 0 && mcpServerCount === 0 && blockedMcpServerCount === 0 && openFileCount === 0 @@ -58,19 +58,19 @@ export const ContextSummaryDisplay: React.FC = ({ return `${fileText} ${t('(ctrl+g to view)')}`; })(); - const geminiMdText = (() => { - if (geminiMdFileCount === 0) { + const memoryFileText = (() => { + if (memoryFileCount === 0) { return ''; } const allNamesTheSame = new Set(contextFileNames).size < 2; const name = allNamesTheSame ? contextFileNames[0] : 'context'; - return geminiMdFileCount === 1 + return memoryFileCount === 1 ? t('{{count}} {{name}} file', { - count: String(geminiMdFileCount), + count: String(memoryFileCount), name, }) : t('{{count}} {{name}} files', { - count: String(geminiMdFileCount), + count: String(memoryFileCount), name, }); })(); @@ -118,7 +118,7 @@ export const ContextSummaryDisplay: React.FC = ({ return text; })(); - const summaryParts = [openFilesText, geminiMdText, mcpText].filter(Boolean); + const summaryParts = [openFilesText, memoryFileText, mcpText].filter(Boolean); if (isNarrow) { return ( diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 9c2d5113eb7..52785712b62 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -97,7 +97,7 @@ const createMockUIState = (overrides: Partial = {}): UIState => }, currentModel: 'gemini-pro', branchName: undefined, - geminiMdFileCount: 0, + memoryFileCount: 0, contextFileNames: [], showToolDescriptions: false, ideContextState: undefined, diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index 8b9a89e59e4..a4f883d1495 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -172,7 +172,7 @@ const createUIState = (overrides: Partial = {}): UIState => settingInputRequests: [], pluginChoiceRequests: [], loopDetectionConfirmationRequest: null, - geminiMdFileCount: 0, + memoryFileCount: 0, streamingState: {} as UIState['streamingState'], initError: null, pendingGeminiHistoryItems: [], diff --git a/packages/cli/src/ui/components/RespondingSpinner.tsx b/packages/cli/src/ui/components/RespondingSpinner.tsx index 119b2998d90..39ade3ae114 100644 --- a/packages/cli/src/ui/components/RespondingSpinner.tsx +++ b/packages/cli/src/ui/components/RespondingSpinner.tsx @@ -7,7 +7,7 @@ import type React from 'react'; import { useEffect, useState } from 'react'; import { Text, useIsScreenReaderEnabled } from 'ink'; -import Spinner from 'ink-spinner'; +import InkSpinner from 'ink-spinner'; import type { SpinnerName } from 'cli-spinners'; import { useStreamingContext } from '../contexts/StreamingContext.js'; import { StreamingState } from '../types.js'; @@ -29,17 +29,15 @@ interface RespondingSpinnerProps { spinnerType?: SpinnerName; } -export const RespondingSpinner: React.FC< - RespondingSpinnerProps -> = ({ nonRespondingDisplay, spinnerType = 'dots' }) => { +export const RespondingSpinner: React.FC = ({ + nonRespondingDisplay, + spinnerType = 'dots', +}) => { const streamingState = useStreamingContext(); const isScreenReaderEnabled = useIsScreenReaderEnabled(); if (streamingState === StreamingState.Responding) { return ( - + ); } else if (nonRespondingDisplay) { return isScreenReaderEnabled ? ( @@ -94,7 +92,7 @@ export const Spinner: React.FC = ({ return ( - + ); }; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 402e4c20a2e..c544fbd66d6 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -97,7 +97,7 @@ export interface UIState { settingInputRequests: SettingInputRequest[]; pluginChoiceRequests: PluginChoiceRequest[]; loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null; - geminiMdFileCount: number; + memoryFileCount: number; streamingState: StreamingState; initError: string | null; pendingGeminiHistoryItems: HistoryItemWithoutId[]; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index a62e0b0ad80..13a6a55efdf 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -287,7 +287,7 @@ describe('useGeminiStream', () => { mcpServers: undefined, userAgent: 'test-agent', userMemory: '', - geminiMdFileCount: 0, + memoryFileCount: 0, alwaysSkipModificationConfirmation: false, vertexai: false, contextFileName: undefined, diff --git a/packages/cli/src/ui/startInteractiveUI.test.tsx b/packages/cli/src/ui/startInteractiveUI.test.tsx index 2ffe8318d43..04995c70019 100644 --- a/packages/cli/src/ui/startInteractiveUI.test.tsx +++ b/packages/cli/src/ui/startInteractiveUI.test.tsx @@ -79,7 +79,7 @@ const initializationResult = { authError: null, themeError: null, shouldOpenAuthDialog: false, - geminiMdFileCount: 0, + memoryFileCount: 0, } as InitializationResult; async function start(config: Config = makeConfig()): Promise { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 52dba22629c..f4c0e6957ae 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1081,7 +1081,7 @@ export interface ConfigParameters { }; lspClient?: LspClient; userMemory?: string; - geminiMdFileCount?: number; + memoryFileCount?: number; approvalMode?: ApprovalMode; contextFileName?: string | string[]; accessibility?: AccessibilitySettings; @@ -2038,7 +2038,7 @@ export class Config { */ private autoMemoryPrompt = ''; private sdkMode: boolean; - private geminiMdFileCount: number; + private memoryFileCount: number; private loadedContextFilePaths: string[] = []; private conditionalRulesRegistry: ConditionalRulesRegistry | undefined; private readonly contextRuleExcludes: string[]; @@ -2341,7 +2341,7 @@ export class Config { this.sessionSubagents = params.sessionSubagents ?? []; this.sdkMode = params.sdkMode ?? false; this.userMemory = params.userMemory ?? ''; - this.geminiMdFileCount = params.geminiMdFileCount ?? 0; + this.memoryFileCount = params.memoryFileCount ?? 0; this.contextRuleExcludes = params.contextRuleExcludes ?? []; this.approvalMode = params.approvalMode ?? ApprovalMode.AUTO; this.accessibility = params.accessibility ?? {}; @@ -6378,11 +6378,11 @@ export class Config { } getMemoryFileCount(): number { - return this.geminiMdFileCount; + return this.memoryFileCount; } setMemoryFileCount(count: number): void { - this.geminiMdFileCount = count; + this.memoryFileCount = count; } /** Display paths of the currently loaded context (memory) files. */ diff --git a/packages/core/src/memory/memoryDiscovery.ts b/packages/core/src/memory/memoryDiscovery.ts index bb67c538784..e8b78745a51 100644 --- a/packages/core/src/memory/memoryDiscovery.ts +++ b/packages/core/src/memory/memoryDiscovery.ts @@ -100,24 +100,24 @@ async function getMemoryFilePathsInternalForEachDir( implicitDiscoveryEnabled: boolean = true, ): Promise { const allPaths = new Set(); - const geminiMdFilenames = getAllMemoryFilenames(); + const memoryFilenames = getAllMemoryFilenames(); - for (const geminiMdFilename of geminiMdFilenames) { + for (const memoryFilename of memoryFilenames) { const resolvedHome = path.resolve(userHomePath); const globalQwenDir = Storage.getGlobalQwenDir(); - const globalMemoryPath = path.join(globalQwenDir, geminiMdFilename); + const globalMemoryPath = path.join(globalQwenDir, memoryFilename); // Handle the case where we're in the home directory (dir is empty string or home path) const resolvedDir = dir ? path.resolve(dir) : resolvedHome; const isHomeDirectory = resolvedDir === resolvedHome; if (!implicitDiscoveryEnabled) { - const explicitContextPath = path.join(resolvedDir, geminiMdFilename); + const explicitContextPath = path.join(resolvedDir, memoryFilename); try { await fs.access(explicitContextPath, fsSync.constants.R_OK); allPaths.add(explicitContextPath); logger.debug( - `Found readable explicit ${geminiMdFilename}: ${explicitContextPath}`, + `Found readable explicit ${memoryFilename}: ${explicitContextPath}`, ); } catch { // Not found, which is okay for explicit-only discovery. @@ -128,7 +128,7 @@ async function getMemoryFilePathsInternalForEachDir( await fs.access(globalMemoryPath, fsSync.constants.R_OK); allPaths.add(globalMemoryPath); logger.debug( - `Found readable global ${geminiMdFilename}: ${globalMemoryPath}`, + `Found readable global ${memoryFilename}: ${globalMemoryPath}`, ); } catch { // It's okay if it's not found. @@ -141,13 +141,13 @@ async function getMemoryFilePathsInternalForEachDir( if (isHomeDirectory) { // For home directory, only check for QWEN.md directly in the home directory - const homeContextPath = path.join(resolvedHome, geminiMdFilename); + const homeContextPath = path.join(resolvedHome, memoryFilename); try { await fs.access(homeContextPath, fsSync.constants.R_OK); if (homeContextPath !== globalMemoryPath) { allPaths.add(homeContextPath); logger.debug( - `Found readable home ${geminiMdFilename}: ${homeContextPath}`, + `Found readable home ${memoryFilename}: ${homeContextPath}`, ); } } catch { @@ -158,7 +158,7 @@ async function getMemoryFilePathsInternalForEachDir( // if a valid currentWorkingDirectory is provided and it's not the home directory. const resolvedCwd = path.resolve(dir); logger.debug( - `Searching for ${geminiMdFilename} starting from CWD: ${resolvedCwd}`, + `Searching for ${memoryFilename} starting from CWD: ${resolvedCwd}`, ); const projectRoot = await findProjectRoot(resolvedCwd); @@ -178,7 +178,7 @@ async function getMemoryFilePathsInternalForEachDir( break; } - const potentialPath = path.join(currentDir, geminiMdFilename); + const potentialPath = path.join(currentDir, memoryFilename); try { await fs.access(potentialPath, fsSync.constants.R_OK); if (potentialPath !== globalMemoryPath) { diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index fcf221a4d3b..cd5664b8397 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -110,7 +110,7 @@ const baseConfigParams: ConfigParameters = { targetDir: '/test/dir', debugMode: false, userMemory: '', - geminiMdFileCount: 0, + memoryFileCount: 0, approvalMode: ApprovalMode.DEFAULT, }; diff --git a/packages/core/src/tools/tool-search.test.ts b/packages/core/src/tools/tool-search.test.ts index 065d7480767..0b8bac60430 100644 --- a/packages/core/src/tools/tool-search.test.ts +++ b/packages/core/src/tools/tool-search.test.ts @@ -29,7 +29,7 @@ const baseConfigParams: ConfigParameters = { targetDir: '/test/dir', debugMode: false, userMemory: '', - geminiMdFileCount: 0, + memoryFileCount: 0, approvalMode: ApprovalMode.DEFAULT, }; From 67dd620df2dbc6f9b8036a6a8776bf892d56742b Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 24 Aug 2026 20:28:39 +0800 Subject: [PATCH 03/15] docs(serve): fix memory filename references --- docs/developers/daemon/02-serve-runtime.md | 4 ++-- docs/developers/daemon/03-acp-bridge.md | 11 +++++------ docs/developers/daemon/17-configuration.md | 3 +-- docs/developers/daemon/20-quickstart-operations.md | 2 +- docs/developers/qwen-serve-protocol.md | 2 +- packages/cli/src/serve/capabilities.ts | 11 +++++------ packages/cli/src/serve/run-qwen-serve.ts | 6 +++--- 7 files changed, 18 insertions(+), 21 deletions(-) diff --git a/docs/developers/daemon/02-serve-runtime.md b/docs/developers/daemon/02-serve-runtime.md index 468084c03d7..0c525099465 100644 --- a/docs/developers/daemon/02-serve-runtime.md +++ b/docs/developers/daemon/02-serve-runtime.md @@ -110,7 +110,7 @@ Calling `createServeApp` directly still returns only an `Application`. An embedd | Upstream used by `serve/` | Downstream using `serve/` | | ----------------------------------------------------------------------------------------------- | ----------------------------------------- | | `@qwen-code/acp-bridge`: bridge, event bus, status types | The `qwen` CLI `serve` subcommand handler | -| `packages/core`: `loadSettings`, `getCurrentMemoryFilename`, `Config`, `WorkspaceContext` | Direct embedders, tests | +| `packages/core`: `getAllMemoryFilenames`, `Config`, `WorkspaceContext` | Direct embedders, tests | | ACP SDK (`@agentclientprotocol/sdk`): `PROTOCOL_VERSION`, `ClientSideConnection` through bridge | | | Express + body-parser, `node:crypto`, `node:fs`, `node:path` | | @@ -136,7 +136,7 @@ Calling `createServeApp` directly still returns only an `Application`. An embedd | Flags | `--session-reap-interval-ms`, `--session-idle-timeout-ms` | Disconnected-session reaping control. | | Flags | `--rate-limit*` | Per-tier HTTP rate limit. | | `settings.json` | `policy.permissionStrategy`, `policy.consensusQuorum` | `MultiClientPermissionMediator` policy and quorum. | -| `settings.json` | `context.fileName` | `getCurrentMemoryFilename` override for the bridge. | +| `settings.json` | `context.fileName` | Workspace memory filename passed to `/workspace/init` through the workspace-service `contextFilename`. | See [`17-configuration.md`](./17-configuration.md) for the merged reference. diff --git a/docs/developers/daemon/03-acp-bridge.md b/docs/developers/daemon/03-acp-bridge.md index c86ddc8b221..51cbadf450e 100644 --- a/docs/developers/daemon/03-acp-bridge.md +++ b/docs/developers/daemon/03-acp-bridge.md @@ -184,11 +184,11 @@ sequenceDiagram ## Dependencies -| Upstream | Downstream | -| ------------------------------------------------------------------------------------------ | ---------------------------------------------- | -| `@agentclientprotocol/sdk` — `ClientSideConnection`, `PROTOCOL_VERSION`, ACP types | `packages/cli/src/serve/` (the daemon) | -| `@qwen-code/qwen-code-core` — `ApprovalMode`, `TrustGateError`, `getCurrentMemoryFilename` | `packages/channels/base/` (planned, F4) | -| `node:crypto`, `node:fs`, `node:path` | `packages/vscode-ide-companion/` (planned, F4) | +| Upstream | Downstream | +| ---------------------------------------------------------------------------------- | ---------------------------------------------- | +| `@agentclientprotocol/sdk` — `ClientSideConnection`, `PROTOCOL_VERSION`, ACP types | `packages/cli/src/serve/` (the daemon) | +| `@qwen-code/qwen-code-core` — `ApprovalMode`, `TrustGateError` | `packages/channels/base/` (planned, F4) | +| `node:crypto`, `node:fs`, `node:path` | `packages/vscode-ide-companion/` (planned, F4) | ## Configuration @@ -208,7 +208,6 @@ sequenceDiagram | `childEnvOverrides` | `{}` | Per-handle env additions / scrubs for the ACP child. | | `externalToolGuard` | (none) | Optional handler for the private child-to-parent pre-execution decision. The bridge accepts it only from the owning channel for the currently active Prompt. | | `persistApprovalMode`, `persistDisabledTools` | — | Settings-write hooks for the Wave 4 mutation routes. | -| `contextFilename` | from `settings.json`'s `context.fileName` | Overrides `getCurrentMemoryFilename`. | | `statusProvider` | (none) | Daemon-host preflight cells (`DaemonStatusProvider`). | | `delegateReadTextFileToClient` | `true` | Set `false` only for same-host runtimes so every child `FileSystemService.readTextFile` consumer uses the regular CLI filesystem service. | | `fileSystem` | (none) | `BridgeFileSystem` adapter for ACP `readTextFile` / `writeTextFile`. | diff --git a/docs/developers/daemon/17-configuration.md b/docs/developers/daemon/17-configuration.md index 4a63bbff534..f0c36c7304f 100644 --- a/docs/developers/daemon/17-configuration.md +++ b/docs/developers/daemon/17-configuration.md @@ -105,7 +105,7 @@ The daemon constructs each workspace runtime from that workspace's merged settin | --------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `policy.permissionStrategy` | `'first-responder' \| 'designated' \| 'consensus' \| 'local-only'` | Sets `BridgeOptions.permissionPolicy`; the active value appears in `/capabilities` as `policy.permission`. **Boot validates** through `validatePolicyConfig()` against `SERVE_CAPABILITY_REGISTRY.permission_mediation.modes`. Unknown literals throw `InvalidPolicyConfigError` and fail boot explicitly. | | `policy.consensusQuorum` | positive integer | N for the `consensus` policy. **Default** is `floor(M/2) + 1` over `votersAtIssue.size` (M=2 means unanimous; larger even M means more than half). If set under a non-consensus policy, it is ignored and boot prints a stderr warning. Non-positive integers throw `InvalidPolicyConfigError`. See [`04-permission-mediation.md`](./04-permission-mediation.md). | -| `context.fileName` | string | Overrides `getCurrentMemoryFilename()` through `BridgeOptions.contextFilename`. | +| `context.fileName` | string | Workspace memory filename. `qwen serve` snapshots it through `extractContextFilename()` and passes it to the workspace service as `contextFilename`; `POST /workspace/init` writes that file. | | `tools.disabled` | string[] | Tools disabled for the next ACP child spawn. Normalized through `normalizeDisabledToolList()` (`packages/cli/src/config/normalizeDisabledTools.ts`): non-array becomes `[]`, non-string entries are skipped, whitespace is trimmed, empty entries are dropped, and duplicates are removed while preserving first occurrence. Boot and `restartMcpServer` settings refresh both run through this function. `ToolRegistry.has(name)` is exact and case-sensitive. `POST /workspace/tools/:name/enable` and `tool_toggled` update this key. | | `tools.approvalMode` | `'default' \| 'auto' \| ...` | Default session approval mode; `POST /session/:id/approval-mode` writes here when `persist: true`. | | `telemetry` | object | OTel config. Keys include `enabled`, `otlpEndpoint`, `otlpProtocol`, `otlpTracesEndpoint`, `otlpLogsEndpoint`, `otlpMetricsEndpoint`, `target`, `outfile`, `userId`, `includeSensitiveSpanAttributes`, `sensitiveSpanAttributeMaxLength`, `resourceAttributes`, and `metrics.includeSessionId`. `resolveTelemetrySettings()` reads it at boot and initializes `initializeTelemetry()`. `userId` is process-wide and must not be configured as end-user identity when the daemon serves multiple users. | @@ -149,7 +149,6 @@ The daemon constructs each workspace runtime from that workspace's merged settin | `statusProvider` | Daemon-host preflight cells. | | `childEnvOverrides` | Per-handle environment additions or removals. | | `externalToolGuard` | Optional daemon-side handler for the private child-to-parent prepare RPC. The bridge validates channel ownership and the active Prompt before and after it calls the handler. | -| `contextFilename` | Overrides `getCurrentMemoryFilename()`. | | `channelIdleTimeoutMs` | How long to keep the ACP child alive after the last session closes, in ms; default `0`. | ## Important defaults diff --git a/docs/developers/daemon/20-quickstart-operations.md b/docs/developers/daemon/20-quickstart-operations.md index 8dea0cfcb0a..890c5463f40 100644 --- a/docs/developers/daemon/20-quickstart-operations.md +++ b/docs/developers/daemon/20-quickstart-operations.md @@ -137,7 +137,7 @@ Boot calls `loadSettings(boundWorkspace)` once: | --------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `policy.permissionStrategy` | `'first-responder' \| 'designated' \| 'consensus' \| 'local-only'` | Sets `BridgeOptions.permissionPolicy`. **Boot validates with `validatePolicyConfig`**; unknown values throw `InvalidPolicyConfigError` instead of falling back silently. | | `policy.consensusQuorum` | positive integer | N for the `consensus` policy. Default is `floor(M/2)+1`. If set under a non-consensus policy, it is ignored and boot logs a stderr warning. | -| `context.fileName` | string | Overrides `getCurrentMemoryFilename()` and controls which file `POST /workspace/init` writes. | +| `context.fileName` | string | Controls which file `POST /workspace/init` writes through the workspace-service `contextFilename`. | | `tools.disabled` | string[] | Normalized through `normalizeDisabledToolList()` (trim, drop empty entries, dedupe) before affecting the next ACP child spawn. | | `tools.approvalMode` | string | Default session approval mode. | | `telemetry` | object | OTel configuration: `enabled`, `otlpEndpoint`, `otlpProtocol`, per-signal endpoints, and more. See [`17-configuration.md`](./17-configuration.md). | diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index e14eb8e4f5e..e10167f3a84 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -2899,7 +2899,7 @@ Target errors use `skill_not_found`, `skill_not_toggleable`, or `skill_inactive_ Capability tag: `workspace_init`. Pure file IO — no ACP roundtrip, **no LLM invocation**. -Scaffold an empty `QWEN.md` (or whatever `getCurrentMemoryFilename()` returns under `--memory-file-name` overrides) at the daemon's primary workspace root. Mechanical only — for AI-driven content fill, follow up with `POST /session/:id/prompt`. +Scaffold an empty `QWEN.md` (or the workspace `context.fileName` settings override) at the daemon's primary workspace root. Mechanical only — for AI-driven content fill, follow up with `POST /session/:id/prompt`. Default refuses to overwrite when the target file exists with non-whitespace content. Whitespace-only files are treated as absent (matches the local `/init` slash command). diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index b77608d27a2..ad32423df68 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -206,13 +206,12 @@ export const SERVE_CAPABILITY_REGISTRY = { // Workspace trust policy changes rebuild the affected runtime generation // without restarting the daemon. V2 trust status exposes convergence. workspace_trust_hot_reload: { since: 'v1' }, - // `POST /workspace/init` scaffolds an empty - // `QWEN.md` (or whatever `getCurrentMemoryFilename()` returns) at - // the bound workspace root. Body: `{force?: boolean}`. Default + // `POST /workspace/init` scaffolds an empty `QWEN.md` (or the + // workspace `context.fileName` value injected as `contextFilename`) + // at the bound workspace root. Body: `{force?: boolean}`. Default // refuses with 409 when the file already exists; `force: true` - // overwrites. Mechanical only — does NOT call the LLM. To AI-fill - // the file, the caller should follow up with - // `POST /session/:id/prompt`. + // overwrites. Mechanical only — does NOT call the LLM. To AI-fill the + // file, the caller should follow up with `POST /session/:id/prompt`. workspace_init: { since: 'v1' }, // `POST /workspace/setup-github` installs the fixed // qwen-code-action workflow set into the bound workspace after diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 9b3c6f49329..d18af2d0972 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -696,9 +696,9 @@ export function formatChannelWorkerDaemonUrl( * - array → first non-empty string element after trim, or undefined * - anything else (object, number, boolean, undefined) → undefined * - * Returning `undefined` is the bridge's signal to use its own - * `getCurrentMemoryFilename()` default — so a malformed value - * keeps the daemon alive rather than producing a garbage filename. + * Returning `undefined` leaves the daemon on its hard-coded `QWEN.md` + * init default — so a malformed value keeps the daemon alive rather + * than producing a garbage filename. */ export function extractContextFilename(value: unknown): string | undefined { if (typeof value === 'string') { From ed97f92ba9d194dcaaf9fbb00cd1f102c84cdaba Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Mon, 24 Aug 2026 23:20:40 +0800 Subject: [PATCH 04/15] test(cli): pin primary workspace QWEN.md init fallback Assert that the primary daemon workspace service receives the hard-coded 'QWEN.md' context filename when boot settings carry no context.fileName. Previously only the secondary workspace's explicit SECONDARY.md resolution was asserted, so swapping the fallback literal at the createDaemonWorkspaceService call site survived the suite. --- packages/cli/src/serve/run-qwen-serve.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index dfa2feafe06..a42c9a867b6 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -2228,6 +2228,15 @@ describe('runQwenServe telemetry validation', () => { ([input]) => input.boundWorkspace === secondaryCwd, )?.[0], ).toMatchObject({ contextFilename: 'SECONDARY.md' }); + // bootSettings above carries no `context.fileName`, so the primary + // workspace must land on the hard-coded `QWEN.md` init default + // (`contextFilenameForInit ?? 'QWEN.md'`). Without this assertion the + // fallback literal could be swapped without any test noticing. + expect( + createWorkspaceService.mock.calls.find( + ([input]) => input.boundWorkspace === canonicalizeWorkspace(primary), + )?.[0], + ).toMatchObject({ contextFilename: 'QWEN.md' }); } finally { await handle.close(); } From 8b1d7750cb7172d086b8c173e2cd3703eae4ebcc Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Tue, 25 Aug 2026 04:55:09 +0800 Subject: [PATCH 05/15] refactor(core,cli): finish Gemini residue rename in memoryDiscovery Complete the rename flagged in review: GeminiFileContent -> MemoryFileContent (module-local interface), includeDirectoriesToReadGemini -> includeDirectoriesToReadMemory (parameter only; all call sites are positional, zero cross-package impact), plus test-local variable names and the stale ORIGINAL_GEMINI_MD_FILENAME test title. --- packages/cli/src/config/config.ts | 4 +-- .../core/src/memory/memoryDiscovery.test.ts | 26 +++++++++---------- packages/core/src/memory/memoryDiscovery.ts | 22 ++++++++-------- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index fd77602c8e3..9bb70e8fc3d 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1178,7 +1178,7 @@ export async function parseArguments(): Promise { // TODO: Consider if App.tsx should get memory via a server call or if Config should refresh itself. export async function loadHierarchicalGeminiMemory( currentWorkingDirectory: string, - includeDirectoriesToReadGemini: readonly string[] = [], + includeDirectoriesToReadMemory: readonly string[] = [], fileService: FileDiscoveryService, extensionContextFilePaths: string[] = [], folderTrust: boolean, @@ -1198,7 +1198,7 @@ export async function loadHierarchicalGeminiMemory( // Directly call the server function with the corrected path. return loadServerHierarchicalMemory( effectiveCwd, - includeDirectoriesToReadGemini, + includeDirectoriesToReadMemory, fileService, extensionContextFilePaths, folderTrust, diff --git a/packages/core/src/memory/memoryDiscovery.test.ts b/packages/core/src/memory/memoryDiscovery.test.ts index 50121c4a067..e66a1838467 100644 --- a/packages/core/src/memory/memoryDiscovery.test.ts +++ b/packages/core/src/memory/memoryDiscovery.test.ts @@ -349,12 +349,12 @@ describe('loadServerHierarchicalMemory', () => { }); }); - it('should load ORIGINAL_GEMINI_MD_FILENAME files by upward traversal from CWD to project root', async () => { - const projectRootGeminiFile = await createTestFile( + it('should load context files by upward traversal with default filename', async () => { + const projectRootMemoryFile = await createTestFile( path.join(projectRoot, DEFAULT_CONTEXT_FILENAME), 'Project root memory', ); - const srcGeminiFile = await createTestFile( + const srcMemoryFile = await createTestFile( path.join(cwd, DEFAULT_CONTEXT_FILENAME), 'Src directory memory', ); @@ -368,11 +368,11 @@ describe('loadServerHierarchicalMemory', () => { ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\nProject root memory\n--- End of Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, srcGeminiFile)} ---\nSrc directory memory\n--- End of Context from: ${path.relative(cwd, srcGeminiFile)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, projectRootMemoryFile)} ---\nProject root memory\n--- End of Context from: ${path.relative(cwd, projectRootMemoryFile)} ---\n\n--- Context from: ${path.relative(cwd, srcMemoryFile)} ---\nSrc directory memory\n--- End of Context from: ${path.relative(cwd, srcMemoryFile)} ---`, fileCount: 2, contextFilePaths: [ - path.relative(cwd, projectRootGeminiFile), - path.relative(cwd, srcGeminiFile), + path.relative(cwd, projectRootMemoryFile), + path.relative(cwd, srcMemoryFile), ], ruleCount: 0, conditionalRules: [], @@ -414,15 +414,15 @@ describe('loadServerHierarchicalMemory', () => { path.join(homedir, QWEN_DIR, DEFAULT_CONTEXT_FILENAME), 'default context content', ); - const rootGeminiFile = await createTestFile( + const rootMemoryFile = await createTestFile( path.join(testRootDir, DEFAULT_CONTEXT_FILENAME), 'Project parent memory', ); - const projectRootGeminiFile = await createTestFile( + const projectRootMemoryFile = await createTestFile( path.join(projectRoot, DEFAULT_CONTEXT_FILENAME), 'Project root memory', ); - const cwdGeminiFile = await createTestFile( + const cwdMemoryFile = await createTestFile( path.join(cwd, DEFAULT_CONTEXT_FILENAME), 'CWD memory', ); @@ -441,13 +441,13 @@ describe('loadServerHierarchicalMemory', () => { // Subdirectory files are not loaded, only global and upward from CWD expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, defaultContextFile)} ---\ndefault context content\n--- End of Context from: ${path.relative(cwd, defaultContextFile)} ---\n\n--- Context from: ${path.relative(cwd, rootGeminiFile)} ---\nProject parent memory\n--- End of Context from: ${path.relative(cwd, rootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\nProject root memory\n--- End of Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, cwdGeminiFile)} ---\nCWD memory\n--- End of Context from: ${path.relative(cwd, cwdGeminiFile)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, defaultContextFile)} ---\ndefault context content\n--- End of Context from: ${path.relative(cwd, defaultContextFile)} ---\n\n--- Context from: ${path.relative(cwd, rootMemoryFile)} ---\nProject parent memory\n--- End of Context from: ${path.relative(cwd, rootMemoryFile)} ---\n\n--- Context from: ${path.relative(cwd, projectRootMemoryFile)} ---\nProject root memory\n--- End of Context from: ${path.relative(cwd, projectRootMemoryFile)} ---\n\n--- Context from: ${path.relative(cwd, cwdMemoryFile)} ---\nCWD memory\n--- End of Context from: ${path.relative(cwd, cwdMemoryFile)} ---`, fileCount: 4, contextFilePaths: [ path.join('~', path.relative(homedir, defaultContextFile)), - path.relative(cwd, rootGeminiFile), - path.relative(cwd, projectRootGeminiFile), - path.relative(cwd, cwdGeminiFile), + path.relative(cwd, rootMemoryFile), + path.relative(cwd, projectRootMemoryFile), + path.relative(cwd, cwdMemoryFile), ], ruleCount: 0, conditionalRules: [], diff --git a/packages/core/src/memory/memoryDiscovery.ts b/packages/core/src/memory/memoryDiscovery.ts index e8b78745a51..4fd8c3e25c5 100644 --- a/packages/core/src/memory/memoryDiscovery.ts +++ b/packages/core/src/memory/memoryDiscovery.ts @@ -27,7 +27,7 @@ import type { const logger = createDebugLogger('MEMORY_DISCOVERY'); -interface GeminiFileContent { +interface MemoryFileContent { filePath: string; content: string | null; } @@ -42,7 +42,7 @@ export interface InstructionsLoadedNotification { async function getMemoryFilePathsInternal( currentWorkingDirectory: string, - includeDirectoriesToReadGemini: readonly string[], + includeDirectoriesToReadMemory: readonly string[], userHomePath: string, fileService: FileDiscoveryService, extensionContextFilePaths: string[] = [], @@ -51,8 +51,8 @@ async function getMemoryFilePathsInternal( ): Promise { const dirs = new Set( implicitDiscoveryEnabled - ? [...includeDirectoriesToReadGemini, currentWorkingDirectory] - : [...includeDirectoriesToReadGemini], + ? [...includeDirectoriesToReadMemory, currentWorkingDirectory] + : [...includeDirectoriesToReadMemory], ); // Process directories in parallel with concurrency limit to prevent EMFILE errors @@ -221,10 +221,10 @@ async function readMemoryFiles( notification: InstructionsLoadedNotification, ) => void | Promise, loadReason: Exclude = 'session_start', -): Promise { +): Promise { // Process files in parallel with concurrency limit to prevent EMFILE errors const CONCURRENT_LIMIT = 20; // Higher limit for file reads as they're typically faster - const results: GeminiFileContent[] = []; + const results: MemoryFileContent[] = []; const notifyInstructionsLoaded = async ( notification: InstructionsLoadedNotification, ) => { @@ -241,7 +241,7 @@ async function readMemoryFiles( for (let i = 0; i < filePaths.length; i += CONCURRENT_LIMIT) { const batch = filePaths.slice(i, i + CONCURRENT_LIMIT); const batchPromises = batch.map( - async (filePath): Promise => { + async (filePath): Promise => { try { const content = await fs.readFile(filePath, 'utf-8'); @@ -347,12 +347,12 @@ export function formatContextFileDisplayPath( // The attachment rule for the system prompt: only non-blank string content // reaches it. Shared by concatenateInstructions and contextFilePaths so the // "displayed = attached" property holds by construction. -function hasAttachedContent(item: GeminiFileContent): boolean { +function hasAttachedContent(item: MemoryFileContent): boolean { return typeof item.content === 'string' && item.content.trim().length > 0; } function concatenateInstructions( - instructionContents: GeminiFileContent[], + instructionContents: MemoryFileContent[], // CWD is needed to resolve relative paths for display markers currentWorkingDirectoryForDisplay: string, ): string { @@ -461,7 +461,7 @@ function createMemoryTypeClassifier( */ export async function loadServerHierarchicalMemory( currentWorkingDirectory: string, - includeDirectoriesToReadGemini: readonly string[], + includeDirectoriesToReadMemory: readonly string[], fileService: FileDiscoveryService, extensionContextFilePaths: string[] = [], folderTrust: boolean, @@ -479,7 +479,7 @@ export async function loadServerHierarchicalMemory( const userHomePath = homedir(); const filePaths = await getMemoryFilePathsInternal( currentWorkingDirectory, - includeDirectoriesToReadGemini, + includeDirectoriesToReadMemory, userHomePath, fileService, extensionContextFilePaths, From 139e1ef48d3e0299df007042299a813fbb584af7 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Tue, 25 Aug 2026 04:55:56 +0800 Subject: [PATCH 06/15] docs(design): route loadHierarchicalGeminiMemory to Memory naming Per exception #1 the Llm prefix is reserved for the generic LLM-client surface; the symbol is a memory-file loader (thin wrapper around core's loadServerHierarchicalMemory), so the PR-2 symbol map targets loadHierarchicalMemory instead of loadHierarchicalLlmMemory. Doc-only: the code symbol is not renamed by this PR. --- .../2026-08-22-rename-gemini-fork-residue.md | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/docs/design/2026-08-22-rename-gemini-fork-residue.md b/docs/design/2026-08-22-rename-gemini-fork-residue.md index 74bd7aa04cc..c00d6e79379 100644 --- a/docs/design/2026-08-22-rename-gemini-fork-residue.md +++ b/docs/design/2026-08-22-rename-gemini-fork-residue.md @@ -29,7 +29,7 @@ Rename the local `Gemini` identifiers to `Llm`, with these exceptions: 2. **UI spinners** — `GeminiRespondingSpinner` / `GeminiSpinner` drop the prefix. → `RespondingSpinner` / `Spinner`. 3. **Gemini extension format (keep as-is)** — `packages/core/src/extension/ - gemini-converter.ts` converts *upstream Gemini CLI extension* configs +gemini-converter.ts` converts _upstream Gemini CLI extension_ configs (`GeminiExtensionConfig`, `convertGeminiToQwenConfig`, `convertGeminiExtensionPackage`, `isGeminiExtensionConfig`). The `Gemini` here denotes a real external format, not the generic LLM client. **Not part @@ -43,51 +43,51 @@ Rename the local `Gemini` identifiers to `Llm`, with these exceptions: Local type/class/enum names (PascalCase, → `Llm*`): -| Current | Definition | New | -|---|---|---| -| `GeminiClient` | `core/src/core/client.ts:375` class | `LlmClient` | -| `GeminiChat` | `core/src/core/geminiChat.ts:1853` class | `LlmChat` | -| `GeminiEventType` | `core/src/core/turn.ts:62` **and** `cli/src/ui/types.ts:42` (two enums) | `LlmEventType` | -| `GeminiContentGenerator` | `core/src/core/geminiContentGenerator/geminiContentGenerator.ts:61` class | `LlmContentGenerator` | -| `GeminiCodeRequest` | `core/src/core/geminiRequest.ts:15` type | `LlmCodeRequest` | -| `GeminiChatSendOptions` | `core/src/core/geminiChat.ts:448` interface | `LlmChatSendOptions` | -| `GeminiErrorEventValue` / `GeminiFinishedEventValue` | `core/src/core/turn.ts:112/122` | `LlmErrorEventValue` / `LlmFinishedEventValue` | -| `GeminiRespondingSpinner` / `GeminiSpinner` | `cli/src/ui/components/GeminiRespondingSpinner.tsx:32/59` | `RespondingSpinner` / `Spinner` | +| Current | Definition | New | +| ---------------------------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------- | +| `GeminiClient` | `core/src/core/client.ts:375` class | `LlmClient` | +| `GeminiChat` | `core/src/core/geminiChat.ts:1853` class | `LlmChat` | +| `GeminiEventType` | `core/src/core/turn.ts:62` **and** `cli/src/ui/types.ts:42` (two enums) | `LlmEventType` | +| `GeminiContentGenerator` | `core/src/core/geminiContentGenerator/geminiContentGenerator.ts:61` class | `LlmContentGenerator` | +| `GeminiCodeRequest` | `core/src/core/geminiRequest.ts:15` type | `LlmCodeRequest` | +| `GeminiChatSendOptions` | `core/src/core/geminiChat.ts:448` interface | `LlmChatSendOptions` | +| `GeminiErrorEventValue` / `GeminiFinishedEventValue` | `core/src/core/turn.ts:112/122` | `LlmErrorEventValue` / `LlmFinishedEventValue` | +| `GeminiRespondingSpinner` / `GeminiSpinner` | `cli/src/ui/components/GeminiRespondingSpinner.tsx:32/59` | `RespondingSpinner` / `Spinner` | camelCase functions/variables (token-infix, → `Llm*`), highest-frequency first: -| Current | New | -|---|---| -| `getGeminiClient` | `getLlmClient` | -| `mockGeminiClient` | `mockLlmClient` | -| `convertOpenAIChunkToGemini` | `convertOpenAIChunkToLlm` | -| `convertGeminiRequestToOpenAI` | `convertLlmRequestToOpenAI` | -| `convertOpenAIResponseToGemini` | `convertOpenAIResponseToLlm` | -| `responseSubmittedToGemini` | `responseSubmittedToLlm` | -| `useGeminiStream` | `useLlmStream` | -| `convertGeminiRequestToAnthropic` | `convertLlmRequestToAnthropic` | +| Current | New | +| ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `getGeminiClient` | `getLlmClient` | +| `mockGeminiClient` | `mockLlmClient` | +| `convertOpenAIChunkToGemini` | `convertOpenAIChunkToLlm` | +| `convertGeminiRequestToOpenAI` | `convertLlmRequestToOpenAI` | +| `convertOpenAIResponseToGemini` | `convertOpenAIResponseToLlm` | +| `responseSubmittedToGemini` | `responseSubmittedToLlm` | +| `useGeminiStream` | `useLlmStream` | +| `convertGeminiRequestToAnthropic` | `convertLlmRequestToAnthropic` | | `setGeminiMdFilename` / `getAllGeminiMdFilenames` / `getCurrentGeminiMdFilename` / `getGeminiMdFileCount` / `setGeminiMdFileCount` | `setMemoryFilename` / `getAllMemoryFilenames` / `getCurrentMemoryFilename` / `getMemoryFileCount` / `setMemoryFileCount` | -| `mockGeminiResponse` / `mockGeminiClientInstance` / `MockedGeminiClientClass` | `mockLlmResponse` / `mockLlmClientInstance` / `MockedLlmClientClass` | -| `convertGeminiToolsToOpenAI` / `convertGeminiToolsToAnthropic` | `convertLlmToolsToOpenAI` / `convertLlmToolsToAnthropic` | -| `convertGeminiToolParametersToOpenAI` | `convertLlmToolParametersToOpenAI` | -| `newGeminiMessageBuffer` / `makeGeminiHistoryItem` / `extractGeminiContent` / `buildGeminiChunk` / `recordGeminiChunk` | `newLlmMessageBuffer` / `makeLlmHistoryItem` / `extractLlmContent` / `buildLlmChunk` / `recordLlmChunk` | -| `createInitializedGeminiClient` / `createGeminiContentGenerator` | `createInitializedLlmClient` / `createLlmContentGenerator` | -| `mapAnthropicFinishReasonToGemini` / `convertAnthropicResponseToGemini` | `mapAnthropicFinishReasonToLlm` / `convertAnthropicResponseToLlm` | -| `pendingGeminiHistoryItems` / `skipGeminiInitialization` / `loadHierarchicalGeminiMemory` | `pendingLlmHistoryItems` / `skipLlmInitialization` / `loadHierarchicalLlmMemory` | +| `mockGeminiResponse` / `mockGeminiClientInstance` / `MockedGeminiClientClass` | `mockLlmResponse` / `mockLlmClientInstance` / `MockedLlmClientClass` | +| `convertGeminiToolsToOpenAI` / `convertGeminiToolsToAnthropic` | `convertLlmToolsToOpenAI` / `convertLlmToolsToAnthropic` | +| `convertGeminiToolParametersToOpenAI` | `convertLlmToolParametersToOpenAI` | +| `newGeminiMessageBuffer` / `makeGeminiHistoryItem` / `extractGeminiContent` / `buildGeminiChunk` / `recordGeminiChunk` | `newLlmMessageBuffer` / `makeLlmHistoryItem` / `extractLlmContent` / `buildLlmChunk` / `recordLlmChunk` | +| `createInitializedGeminiClient` / `createGeminiContentGenerator` | `createInitializedLlmClient` / `createLlmContentGenerator` | +| `mapAnthropicFinishReasonToGemini` / `convertAnthropicResponseToGemini` | `mapAnthropicFinishReasonToLlm` / `convertAnthropicResponseToLlm` | +| `pendingGeminiHistoryItems` / `skipGeminiInitialization` / `loadHierarchicalGeminiMemory` | `pendingLlmHistoryItems` / `skipLlmInitialization` / `loadHierarchicalMemory` | ## File renames Non-test files; `gemini-converter.ts` is intentionally NOT renamed (see above). -| Current | New | -|---|---| -| `packages/cli/src/gemini.tsx` | `packages/cli/src/llm.tsx` | -| `packages/cli/src/ui/components/GeminiRespondingSpinner.tsx` | `packages/cli/src/ui/components/RespondingSpinner.tsx` | -| `packages/cli/src/ui/hooks/useGeminiStream.ts` | `packages/cli/src/ui/hooks/use-llm-stream.ts` | -| `packages/core/src/core/geminiChat.ts` | `packages/core/src/core/llm-chat.ts` | +| Current | New | +| ------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `packages/cli/src/gemini.tsx` | `packages/cli/src/llm.tsx` | +| `packages/cli/src/ui/components/GeminiRespondingSpinner.tsx` | `packages/cli/src/ui/components/RespondingSpinner.tsx` | +| `packages/cli/src/ui/hooks/useGeminiStream.ts` | `packages/cli/src/ui/hooks/use-llm-stream.ts` | +| `packages/core/src/core/geminiChat.ts` | `packages/core/src/core/llm-chat.ts` | | `packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts` | `packages/core/src/core/llm-content-generator/llm-content-generator.ts` | -| `packages/core/src/core/geminiContentGenerator/index.ts` | `packages/core/src/core/llm-content-generator/index.ts` | -| `packages/core/src/core/geminiRequest.ts` | `packages/core/src/core/llm-request.ts` | +| `packages/core/src/core/geminiContentGenerator/index.ts` | `packages/core/src/core/llm-content-generator/index.ts` | +| `packages/core/src/core/geminiRequest.ts` | `packages/core/src/core/llm-request.ts` | ## Phasing From 094e2c66108952e0cbe60a54c0e73e4ba4d3c7c0 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Tue, 25 Aug 2026 04:58:42 +0800 Subject: [PATCH 07/15] docs(cli): narrow extractContextFilename fallback description The undefined fallback first inherits the primary workspace's configured context.fileName snapshot (contextFilenameForInit) at the secondary startup and dynamically added workspace call sites, before the hard-coded QWEN.md. Describe the actual chain instead of the hard-coded default only. Comment-only: the inheritance behavior predates this PR and is unchanged. --- packages/cli/src/serve/run-qwen-serve.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index d18af2d0972..6f77e399466 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -696,9 +696,10 @@ export function formatChannelWorkerDaemonUrl( * - array → first non-empty string element after trim, or undefined * - anything else (object, number, boolean, undefined) → undefined * - * Returning `undefined` leaves the daemon on its hard-coded `QWEN.md` - * init default — so a malformed value keeps the daemon alive rather - * than producing a garbage filename. + * Returning `undefined` leaves the workspace on the daemon's init-default + * chain — the primary workspace's configured `context.fileName` snapshot + * (`contextFilenameForInit`), then the hard-coded `QWEN.md` — so a malformed + * value keeps the daemon alive rather than producing a garbage filename. */ export function extractContextFilename(value: unknown): string | undefined { if (typeof value === 'string') { From aceb66436273ea9e16dfcfdb5e2515213bf2be62 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Tue, 25 Aug 2026 07:32:19 +0800 Subject: [PATCH 08/15] refactor(cli): rename loadHierarchicalGeminiMemory to loadHierarchicalMemory The design doc's symbol map routes the memory loader to loadHierarchicalMemory (memory family, exception #1), but no phasing bullet performed the rename and a prior round left the mixed signature. Complete the rename across the definition (config.ts), the AppContainer call site, and the AppContainer test mocks, and update the design doc's exception #1, symbol map, and PR-1 phasing bullet so the map row is no longer orphaned. --- docs/design/2026-08-22-rename-gemini-fork-residue.md | 12 +++++++++--- packages/cli/src/config/config.ts | 4 ++-- packages/cli/src/ui/AppContainer.test.tsx | 8 ++++---- packages/cli/src/ui/AppContainer.tsx | 4 ++-- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/design/2026-08-22-rename-gemini-fork-residue.md b/docs/design/2026-08-22-rename-gemini-fork-residue.md index c00d6e79379..16d06b93c3c 100644 --- a/docs/design/2026-08-22-rename-gemini-fork-residue.md +++ b/docs/design/2026-08-22-rename-gemini-fork-residue.md @@ -25,7 +25,10 @@ Rename the local `Gemini` identifiers to `Llm`, with these exceptions: 1. **Memory filename** — `GeminiMdFilename` (+ `set/get/getAll/getCurrent`, `GeminiMdFileCount`) is the project memory file (`QWEN.md`), a memory - concept, not an LLM client. → `Memory*`. + concept, not an LLM client. → `Memory*`. The loader + `loadHierarchicalGeminiMemory` (a thin wrapper around core's + `loadServerHierarchicalMemory`) belongs to the same family: → + `loadHierarchicalMemory`. 2. **UI spinners** — `GeminiRespondingSpinner` / `GeminiSpinner` drop the prefix. → `RespondingSpinner` / `Spinner`. 3. **Gemini extension format (keep as-is)** — `packages/core/src/extension/ @@ -73,7 +76,8 @@ camelCase functions/variables (token-infix, → `Llm*`), highest-frequency first | `newGeminiMessageBuffer` / `makeGeminiHistoryItem` / `extractGeminiContent` / `buildGeminiChunk` / `recordGeminiChunk` | `newLlmMessageBuffer` / `makeLlmHistoryItem` / `extractLlmContent` / `buildLlmChunk` / `recordLlmChunk` | | `createInitializedGeminiClient` / `createGeminiContentGenerator` | `createInitializedLlmClient` / `createLlmContentGenerator` | | `mapAnthropicFinishReasonToGemini` / `convertAnthropicResponseToGemini` | `mapAnthropicFinishReasonToLlm` / `convertAnthropicResponseToLlm` | -| `pendingGeminiHistoryItems` / `skipGeminiInitialization` / `loadHierarchicalGeminiMemory` | `pendingLlmHistoryItems` / `skipLlmInitialization` / `loadHierarchicalMemory` | +| `pendingGeminiHistoryItems` / `skipGeminiInitialization` | `pendingLlmHistoryItems` / `skipLlmInitialization` | +| `loadHierarchicalGeminiMemory` | `loadHierarchicalMemory` (memory family, exception #1) | ## File renames @@ -97,7 +101,9 @@ move as one atomic PR. **PR 1 — independent small families** (no cross-package risk, small diff): -- Memory filename: `GeminiMdFilename` family → `Memory*`. +- Memory filename: `GeminiMdFilename` family → `Memory*`, and + `loadHierarchicalGeminiMemory` → `loadHierarchicalMemory` (memory loader, + exception #1). - UI spinners: `GeminiRespondingSpinner` / `GeminiSpinner` → `RespondingSpinner` / `Spinner`, and `GeminiRespondingSpinner.tsx` → `RespondingSpinner.tsx`. diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 9bb70e8fc3d..3f5f5badb75 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1176,7 +1176,7 @@ export async function parseArguments(): Promise { // This function is now a thin wrapper around the server's implementation. // It's kept in the CLI for now as App.tsx directly calls it for memory refresh. // TODO: Consider if App.tsx should get memory via a server call or if Config should refresh itself. -export async function loadHierarchicalGeminiMemory( +export async function loadHierarchicalMemory( currentWorkingDirectory: string, includeDirectoriesToReadMemory: readonly string[] = [], fileService: FileDiscoveryService, @@ -1614,7 +1614,7 @@ export async function loadCliConfig( // Set the context filename in the server's memoryTool module BEFORE loading memory // TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed // directly to the Config constructor in core, and have core handle setMemoryFilename. - // However, loadHierarchicalGeminiMemory is called *before* createServerConfig. + // However, loadHierarchicalMemory is called *before* createServerConfig. if (settings.context?.fileName) { setServerMemoryFilename(settings.context.fileName); } else { diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 97c207df81b..168e5ba931d 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -187,12 +187,12 @@ vi.mock('../utils/events.js'); vi.mock('./handleAutoUpdate.js'); vi.mock('../utils/cleanup.js'); -const mockLoadHierarchicalGeminiMemory = vi.hoisted(() => vi.fn()); +const mockLoadHierarchicalMemory = vi.hoisted(() => vi.fn()); vi.mock('../config/config.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - loadHierarchicalGeminiMemory: mockLoadHierarchicalGeminiMemory, + loadHierarchicalMemory: mockLoadHierarchicalMemory, }; }); @@ -6652,7 +6652,7 @@ describe('AppContainer State Management', () => { }); it('performMemoryRefresh anchors on config.getWorkingDir() and updates contextFilePaths', async () => { - mockLoadHierarchicalGeminiMemory.mockResolvedValue({ + mockLoadHierarchicalMemory.mockResolvedValue({ memoryContent: 'content', fileCount: 1, contextFilePaths: ['/custom/QWEN.md'], @@ -6697,7 +6697,7 @@ describe('AppContainer State Management', () => { await performMemoryRefresh(); }); - expect(mockLoadHierarchicalGeminiMemory).toHaveBeenCalledWith( + expect(mockLoadHierarchicalMemory).toHaveBeenCalledWith( '/custom/workspace', expect.anything(), expect.anything(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ae55e3517a5..05be5d67251 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -87,7 +87,7 @@ import { getStickyTodosRenderKey, } from './utils/todoSnapshot.js'; import type { TodoItem } from './components/TodoDisplay.js'; -import { loadHierarchicalGeminiMemory } from '../config/config.js'; +import { loadHierarchicalMemory } from '../config/config.js'; import { profileCheckpoint, finalizeStartupProfile, @@ -2093,7 +2093,7 @@ export const AppContainer = (props: AppContainerProps) => { contextFilePaths, conditionalRules, projectRoot, - } = await loadHierarchicalGeminiMemory( + } = await loadHierarchicalMemory( config.getWorkingDir(), settings.merged.context?.loadFromIncludeDirectories ? config.getWorkspaceContext().getDirectories() From 2c53c53dd5311720aed1d966e7bc5888ddfc16de Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 26 Aug 2026 14:50:11 +0800 Subject: [PATCH 09/15] fix(core): preserve Gemini rename compatibility --- .../2026-08-22-rename-gemini-fork-residue.md | 15 +++++++++++++ eslint.legacy-filenames.mjs | 1 + packages/core/src/config/config.test.ts | 22 +++++++++++++++++++ packages/core/src/config/config.ts | 15 ++++++++++++- packages/core/src/core/geminiChat.ts | 6 ++++- packages/core/src/core/geminiRequest.ts | 8 +++++++ packages/core/src/core/llm-request.test.ts | 5 +++++ packages/core/src/core/llm-request.ts | 6 +++++ packages/core/src/core/turn.ts | 6 +++++ packages/core/src/memory/const.test.ts | 8 +++++++ packages/core/src/tools/memory-config.ts | 3 +++ packages/core/src/utils/memory-constants.ts | 9 ++++++++ 12 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/core/geminiRequest.ts diff --git a/docs/design/2026-08-22-rename-gemini-fork-residue.md b/docs/design/2026-08-22-rename-gemini-fork-residue.md index 16d06b93c3c..ba86ffc3e22 100644 --- a/docs/design/2026-08-22-rename-gemini-fork-residue.md +++ b/docs/design/2026-08-22-rename-gemini-fork-residue.md @@ -42,6 +42,11 @@ gemini-converter.ts` converts _upstream Gemini CLI extension_ configs (mostly `cli/src/acp-integration`). These belong to `#4063` item 1 (de-Google the type system), not this rename. +`@qwen-code/qwen-code-core` is also published as a standalone package. Names +already exported from its root barrel, `Config`, or supported deep-import paths +remain as deprecated aliases for one release; repository consumers use only +the new names. + ## Symbol map Local type/class/enum names (PascalCase, → `Llm*`): @@ -93,6 +98,8 @@ Non-test files; `gemini-converter.ts` is intentionally NOT renamed (see above). | `packages/core/src/core/geminiContentGenerator/index.ts` | `packages/core/src/core/llm-content-generator/index.ts` | | `packages/core/src/core/geminiRequest.ts` | `packages/core/src/core/llm-request.ts` | +The old `geminiRequest.ts` path remains as a one-release re-export shim. + ## Phasing Two pull requests. The core LLM symbols are strongly coupled (`GeminiClient` @@ -110,6 +117,9 @@ move as one atomic PR. - Leaf types: `GeminiCodeRequest`, `GeminiChatSendOptions`, `GeminiErrorEventValue`, `GeminiFinishedEventValue`, and `geminiRequest.ts` → `llm-request.ts`. +- One-release deprecated aliases for the public core exports, `Config` memory + count input/accessors, memory filename helpers, and the old request module + path. **PR 2 — core LLM layer (atomic)**: @@ -130,6 +140,9 @@ move as one atomic PR. - **Cross-package barrel**: `GeminiClient` and `GeminiEventType` are exported via the `@qwen-code/qwen-code-core` barrel. `sdk-typescript` and `acp-bridge` import them; PR 2 must update those packages. +- **Published package compatibility**: renamed public symbols remain as + deprecated aliases for one release. Remove those aliases only after a stable + release has shipped the replacement names. - **Two `GeminiEventType` enums**: `core/src/core/turn.ts` and `cli/src/ui/types.ts` define the same name. Rename both and verify their relationship (distinct enums vs re-export) before PR 2. @@ -143,4 +156,6 @@ move as one atomic PR. - `cd packages/core && npx tsc --noEmit` - `cd packages/cli && npx tsc --noEmit` - Targeted unit tests per renamed module +- Legacy-name grep results are confined to the documented compatibility aliases + and `geminiRequest.ts` shim; active repository consumers use the new names. - `npm run lint` (kebab-case filenames are enforced) diff --git a/eslint.legacy-filenames.mjs b/eslint.legacy-filenames.mjs index b1a74313992..a2839e04beb 100644 --- a/eslint.legacy-filenames.mjs +++ b/eslint.legacy-filenames.mjs @@ -134,6 +134,7 @@ export const legacyFilenames = [ 'functionHookRunner', 'geminiChat', 'geminiContentGenerator', + 'geminiRequest', 'generateContentResponseUtilities', 'generatedFiles', 'getFolderStructure', diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 9f0330e2181..1b9eec828a9 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -607,6 +607,28 @@ describe('Server Config (config.ts)', () => { }); }); + describe('memory file count compatibility', () => { + it('keeps the legacy parameter and accessors working for one release', () => { + const config = new Config({ ...baseParams, geminiMdFileCount: 2 }); + + expect(config.getMemoryFileCount()).toBe(2); + expect(config.getGeminiMdFileCount()).toBe(2); + + config.setGeminiMdFileCount(3); + expect(config.getMemoryFileCount()).toBe(3); + }); + + it('prefers the renamed parameter when both names are present', () => { + const config = new Config({ + ...baseParams, + geminiMdFileCount: 2, + memoryFileCount: 4, + }); + + expect(config.getMemoryFileCount()).toBe(4); + }); + }); + describe('getMemoryAgentTimeoutMinutes', () => { it('returns undefined when unset', () => { expect( diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index a82b7424135..be4309c91e1 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1082,6 +1082,8 @@ export interface ConfigParameters { lspClient?: LspClient; userMemory?: string; memoryFileCount?: number; + /** @deprecated Use `memoryFileCount`; retained for one release. */ + geminiMdFileCount?: number; approvalMode?: ApprovalMode; contextFileName?: string | string[]; accessibility?: AccessibilitySettings; @@ -2335,7 +2337,8 @@ export class Config { this.sessionSubagents = params.sessionSubagents ?? []; this.sdkMode = params.sdkMode ?? false; this.userMemory = params.userMemory ?? ''; - this.memoryFileCount = params.memoryFileCount ?? 0; + this.memoryFileCount = + params.memoryFileCount ?? params.geminiMdFileCount ?? 0; this.contextRuleExcludes = params.contextRuleExcludes ?? []; this.approvalMode = params.approvalMode ?? ApprovalMode.AUTO; this.accessibility = params.accessibility ?? {}; @@ -6376,6 +6379,16 @@ export class Config { this.memoryFileCount = count; } + /** @deprecated Use `getMemoryFileCount`; retained for one release. */ + getGeminiMdFileCount(): number { + return this.getMemoryFileCount(); + } + + /** @deprecated Use `setMemoryFileCount`; retained for one release. */ + setGeminiMdFileCount(count: number): void { + this.setMemoryFileCount(count); + } + /** Display paths of the currently loaded context (memory) files. */ getContextFilePaths(): string[] { return this.loadedContextFilePaths; diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 5b7934c28ca..a97ed784555 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -450,6 +450,9 @@ export interface LlmChatSendOptions { disableModelFallbacks?: boolean; } +/** @deprecated Use `LlmChatSendOptions`; retained for one release. */ +export type GeminiChatSendOptions = LlmChatSendOptions; + interface TryCompressOptions { originalTokenCountOverride?: number; trigger?: CompactTrigger; @@ -1942,7 +1945,8 @@ export class GeminiChat { */ private pendingPartialAssistantTurnIndex: number | null = null; private pendingPartialAssistantRecord: - Parameters[0] | null = null; + | Parameters[0] + | null = null; private readonly imagePayloadStore = new InMemoryImagePayloadStore(); diff --git a/packages/core/src/core/geminiRequest.ts b/packages/core/src/core/geminiRequest.ts new file mode 100644 index 00000000000..3ef85cb3272 --- /dev/null +++ b/packages/core/src/core/geminiRequest.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @deprecated Import from `llm-request.js`; retained for one release. */ +export * from './llm-request.js'; diff --git a/packages/core/src/core/llm-request.test.ts b/packages/core/src/core/llm-request.test.ts index 5450ddeac1e..4fa17c2e1b1 100644 --- a/packages/core/src/core/llm-request.test.ts +++ b/packages/core/src/core/llm-request.test.ts @@ -6,9 +6,14 @@ import { describe, it, expect } from 'vitest'; import { partListUnionToString } from './llm-request.js'; +import { partListUnionToString as legacyPartListUnionToString } from './geminiRequest.js'; import { type Part } from '@google/genai'; describe('partListUnionToString', () => { + it('keeps the legacy module path working during the rename window', () => { + expect(legacyPartListUnionToString('hello')).toBe('hello'); + }); + it('should return the string value if the input is a string', () => { const result = partListUnionToString('hello'); expect(result).toBe('hello'); diff --git a/packages/core/src/core/llm-request.ts b/packages/core/src/core/llm-request.ts index d36694b535d..3315869d54f 100644 --- a/packages/core/src/core/llm-request.ts +++ b/packages/core/src/core/llm-request.ts @@ -14,6 +14,12 @@ import { partToString } from '../utils/partUtils.js'; */ export type LlmCodeRequest = PartListUnion; +/** + * @deprecated Use `LlmCodeRequest`. Kept for one release so standalone core + * package consumers can migrate without a breaking rename. + */ +export type GeminiCodeRequest = LlmCodeRequest; + export function partListUnionToString(value: PartListUnion): string { return partToString(value, { verbose: true }); } diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 850de7421e5..3e147e5b4eb 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -124,6 +124,12 @@ export interface LlmFinishedEventValue { usageMetadata: GenerateContentResponseUsageMetadata | undefined; } +/** @deprecated Use `LlmErrorEventValue`; retained for one release. */ +export type GeminiErrorEventValue = LlmErrorEventValue; + +/** @deprecated Use `LlmFinishedEventValue`; retained for one release. */ +export type GeminiFinishedEventValue = LlmFinishedEventValue; + export interface ToolCallRequestInfo { callId: string; /** diff --git a/packages/core/src/memory/const.test.ts b/packages/core/src/memory/const.test.ts index 076e88a4c7e..23f3894a289 100644 --- a/packages/core/src/memory/const.test.ts +++ b/packages/core/src/memory/const.test.ts @@ -13,9 +13,11 @@ import { getAllMemoryFilenames, } from '../utils/memory-constants.js'; import { + getAllGeminiMdFilenames as getToolAllGeminiMdFilenames, setMemoryFilename as setToolMemoryFilename, getCurrentMemoryFilename as getToolCurrentMemoryFilename, getAllMemoryFilenames as getToolAllMemoryFilenames, + setGeminiMdFilename as setToolGeminiMdFilename, } from '../tools/memory-config.js'; // Mock dependencies @@ -66,4 +68,10 @@ describe('setMemoryFilename', () => { expect(getCurrentMemoryFilename()).toBe('LEGACY_CONTEXT.md'); expect(getAllMemoryFilenames()).toEqual(['LEGACY_CONTEXT.md']); }); + + it('keeps the legacy public names wired to the renamed state', () => { + setToolGeminiMdFilename('LEGACY_NAME.md'); + expect(getCurrentMemoryFilename()).toBe('LEGACY_NAME.md'); + expect(getToolAllGeminiMdFilenames()).toEqual(['LEGACY_NAME.md']); + }); }); diff --git a/packages/core/src/tools/memory-config.ts b/packages/core/src/tools/memory-config.ts index 0ba210912bb..6adff0ac01e 100644 --- a/packages/core/src/tools/memory-config.ts +++ b/packages/core/src/tools/memory-config.ts @@ -12,8 +12,11 @@ export { AGENT_CONTEXT_FILENAME, DEFAULT_CONTEXT_FILENAME, + getAllGeminiMdFilenames, getAllMemoryFilenames, + getCurrentGeminiMdFilename, getCurrentMemoryFilename, MEMORY_SECTION_HEADER, + setGeminiMdFilename, setMemoryFilename, } from '../utils/memory-constants.js'; diff --git a/packages/core/src/utils/memory-constants.ts b/packages/core/src/utils/memory-constants.ts index d286510fbe2..d6f0cf86af1 100644 --- a/packages/core/src/utils/memory-constants.ts +++ b/packages/core/src/utils/memory-constants.ts @@ -74,3 +74,12 @@ export function getAllMemoryFilenames(): string[] { } return [currentMemoryFilename]; } + +/** @deprecated Use `setMemoryFilename`; retained for one release. */ +export const setGeminiMdFilename = setMemoryFilename; + +/** @deprecated Use `getCurrentMemoryFilename`; retained for one release. */ +export const getCurrentGeminiMdFilename = getCurrentMemoryFilename; + +/** @deprecated Use `getAllMemoryFilenames`; retained for one release. */ +export const getAllGeminiMdFilenames = getAllMemoryFilenames; From 74bf7922c4fc26f0eb1303b842b5062ca9ad02b9 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 26 Aug 2026 14:53:33 +0800 Subject: [PATCH 10/15] docs(core): extend Gemini deprecation window --- .../2026-08-22-rename-gemini-fork-residue.md | 17 +++++++++-------- packages/core/src/config/config.test.ts | 2 +- packages/core/src/config/config.ts | 6 +++--- packages/core/src/core/geminiChat.ts | 2 +- packages/core/src/core/geminiRequest.ts | 2 +- packages/core/src/core/llm-request.ts | 4 ++-- packages/core/src/core/turn.ts | 4 ++-- packages/core/src/utils/memory-constants.ts | 6 +++--- 8 files changed, 22 insertions(+), 21 deletions(-) diff --git a/docs/design/2026-08-22-rename-gemini-fork-residue.md b/docs/design/2026-08-22-rename-gemini-fork-residue.md index ba86ffc3e22..397518c101e 100644 --- a/docs/design/2026-08-22-rename-gemini-fork-residue.md +++ b/docs/design/2026-08-22-rename-gemini-fork-residue.md @@ -44,8 +44,8 @@ gemini-converter.ts` converts _upstream Gemini CLI extension_ configs `@qwen-code/qwen-code-core` is also published as a standalone package. Names already exported from its root barrel, `Config`, or supported deep-import paths -remain as deprecated aliases for one release; repository consumers use only -the new names. +remain as deprecated aliases until a future major release; repository consumers +use only the new names. ## Symbol map @@ -98,7 +98,8 @@ Non-test files; `gemini-converter.ts` is intentionally NOT renamed (see above). | `packages/core/src/core/geminiContentGenerator/index.ts` | `packages/core/src/core/llm-content-generator/index.ts` | | `packages/core/src/core/geminiRequest.ts` | `packages/core/src/core/llm-request.ts` | -The old `geminiRequest.ts` path remains as a one-release re-export shim. +The old `geminiRequest.ts` path remains as a deprecated re-export shim until a +future major release. ## Phasing @@ -117,9 +118,9 @@ move as one atomic PR. - Leaf types: `GeminiCodeRequest`, `GeminiChatSendOptions`, `GeminiErrorEventValue`, `GeminiFinishedEventValue`, and `geminiRequest.ts` → `llm-request.ts`. -- One-release deprecated aliases for the public core exports, `Config` memory - count input/accessors, memory filename helpers, and the old request module - path. +- Deprecated compatibility aliases for the public core exports, `Config` + memory count input/accessors, memory filename helpers, and the old request + module path. **PR 2 — core LLM layer (atomic)**: @@ -141,8 +142,8 @@ move as one atomic PR. the `@qwen-code/qwen-code-core` barrel. `sdk-typescript` and `acp-bridge` import them; PR 2 must update those packages. - **Published package compatibility**: renamed public symbols remain as - deprecated aliases for one release. Remove those aliases only after a stable - release has shipped the replacement names. + deprecated aliases until a future major release. Remove them only in a planned + major release after consumers have had time to migrate. - **Two `GeminiEventType` enums**: `core/src/core/turn.ts` and `cli/src/ui/types.ts` define the same name. Rename both and verify their relationship (distinct enums vs re-export) before PR 2. diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 1b9eec828a9..6ab4d076290 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -608,7 +608,7 @@ describe('Server Config (config.ts)', () => { }); describe('memory file count compatibility', () => { - it('keeps the legacy parameter and accessors working for one release', () => { + it('keeps the legacy parameter and accessors until a future major release', () => { const config = new Config({ ...baseParams, geminiMdFileCount: 2 }); expect(config.getMemoryFileCount()).toBe(2); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index be4309c91e1..f283a83b86e 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1082,7 +1082,7 @@ export interface ConfigParameters { lspClient?: LspClient; userMemory?: string; memoryFileCount?: number; - /** @deprecated Use `memoryFileCount`; retained for one release. */ + /** @deprecated Use `memoryFileCount`; retained until a future major release. */ geminiMdFileCount?: number; approvalMode?: ApprovalMode; contextFileName?: string | string[]; @@ -6379,12 +6379,12 @@ export class Config { this.memoryFileCount = count; } - /** @deprecated Use `getMemoryFileCount`; retained for one release. */ + /** @deprecated Use `getMemoryFileCount`; retained until a future major release. */ getGeminiMdFileCount(): number { return this.getMemoryFileCount(); } - /** @deprecated Use `setMemoryFileCount`; retained for one release. */ + /** @deprecated Use `setMemoryFileCount`; retained until a future major release. */ setGeminiMdFileCount(count: number): void { this.setMemoryFileCount(count); } diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index a97ed784555..fe5d04404cd 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -450,7 +450,7 @@ export interface LlmChatSendOptions { disableModelFallbacks?: boolean; } -/** @deprecated Use `LlmChatSendOptions`; retained for one release. */ +/** @deprecated Use `LlmChatSendOptions`; retained until a future major release. */ export type GeminiChatSendOptions = LlmChatSendOptions; interface TryCompressOptions { diff --git a/packages/core/src/core/geminiRequest.ts b/packages/core/src/core/geminiRequest.ts index 3ef85cb3272..e0f89a7b76a 100644 --- a/packages/core/src/core/geminiRequest.ts +++ b/packages/core/src/core/geminiRequest.ts @@ -4,5 +4,5 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @deprecated Import from `llm-request.js`; retained for one release. */ +/** @deprecated Import from `llm-request.js`; retained until a future major release. */ export * from './llm-request.js'; diff --git a/packages/core/src/core/llm-request.ts b/packages/core/src/core/llm-request.ts index 3315869d54f..a69e4c52d67 100644 --- a/packages/core/src/core/llm-request.ts +++ b/packages/core/src/core/llm-request.ts @@ -15,8 +15,8 @@ import { partToString } from '../utils/partUtils.js'; export type LlmCodeRequest = PartListUnion; /** - * @deprecated Use `LlmCodeRequest`. Kept for one release so standalone core - * package consumers can migrate without a breaking rename. + * @deprecated Use `LlmCodeRequest`. Retained until a future major release so + * standalone core package consumers can migrate without a breaking rename. */ export type GeminiCodeRequest = LlmCodeRequest; diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 3e147e5b4eb..5d805fd652e 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -124,10 +124,10 @@ export interface LlmFinishedEventValue { usageMetadata: GenerateContentResponseUsageMetadata | undefined; } -/** @deprecated Use `LlmErrorEventValue`; retained for one release. */ +/** @deprecated Use `LlmErrorEventValue`; retained until a future major release. */ export type GeminiErrorEventValue = LlmErrorEventValue; -/** @deprecated Use `LlmFinishedEventValue`; retained for one release. */ +/** @deprecated Use `LlmFinishedEventValue`; retained until a future major release. */ export type GeminiFinishedEventValue = LlmFinishedEventValue; export interface ToolCallRequestInfo { diff --git a/packages/core/src/utils/memory-constants.ts b/packages/core/src/utils/memory-constants.ts index d6f0cf86af1..4025dcb699c 100644 --- a/packages/core/src/utils/memory-constants.ts +++ b/packages/core/src/utils/memory-constants.ts @@ -75,11 +75,11 @@ export function getAllMemoryFilenames(): string[] { return [currentMemoryFilename]; } -/** @deprecated Use `setMemoryFilename`; retained for one release. */ +/** @deprecated Use `setMemoryFilename`; retained until a future major release. */ export const setGeminiMdFilename = setMemoryFilename; -/** @deprecated Use `getCurrentMemoryFilename`; retained for one release. */ +/** @deprecated Use `getCurrentMemoryFilename`; retained until a future major release. */ export const getCurrentGeminiMdFilename = getCurrentMemoryFilename; -/** @deprecated Use `getAllMemoryFilenames`; retained for one release. */ +/** @deprecated Use `getAllMemoryFilenames`; retained until a future major release. */ export const getAllGeminiMdFilenames = getAllMemoryFilenames; From bd93701cf443c9d15f3b0150245243fcbf50477b Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 26 Aug 2026 17:29:36 +0800 Subject: [PATCH 11/15] refactor(core,cli): rename Gemini LLM identifiers --- .../2026-08-22-rename-gemini-fork-residue.md | 7 +- .../daemon/20-quickstart-operations.md | 4 +- .../2026-05-19-oom-reproduction-report.md | 8 +- docs/e2e-tests/worktree-phase-d.md | 2 +- docs/users/features/tool-use-summaries.md | 2 +- eslint.legacy-filenames.mjs | 2 - packages/acp-bridge/src/bridge.ts | 2 +- .../cli/src/acp-integration/acpAgent.test.ts | 76 +- packages/cli/src/acp-integration/acpAgent.ts | 47 +- .../acp-integration/acpAgent.worktree.test.ts | 2 +- .../cli/src/acp-integration/generation.ts | 2 +- .../session/Session.review-lease.test.ts | 10 +- .../acp-integration/session/Session.test.ts | 191 +- .../src/acp-integration/session/Session.ts | 61 +- .../session/Session.worktree.test.ts | 10 +- .../session/history-replay-page.test.ts | 4 +- .../session/history-replay-page.ts | 2 +- .../session/history-replayer.test.ts | 4 +- .../session/rewrite/LlmRewriter.test.ts | 2 +- packages/cli/src/cli.test.ts | 6 +- packages/cli/src/cli.ts | 4 +- packages/cli/src/commands/serve.ts | 2 +- packages/cli/src/config/config.test.ts | 12 +- packages/cli/src/config/config.ts | 4 +- packages/cli/src/config/settings.test.ts | 2 +- .../cli/src/dualOutput/DualOutputBridge.ts | 4 +- .../cli/src/{gemini.test.tsx => llm.test.tsx} | 10 +- packages/cli/src/{gemini.tsx => llm.tsx} | 2 +- .../io/BaseJsonOutputAdapter.test.ts | 40 +- .../io/BaseJsonOutputAdapter.ts | 28 +- .../io/JsonOutputAdapter.test.ts | 73 +- ...StreamJsonOutputAdapter.dualOutput.test.ts | 4 +- .../io/StreamJsonOutputAdapter.test.ts | 110 +- .../io/StreamJsonOutputAdapter.ts | 10 +- .../cli/src/nonInteractive/session.test.ts | 28 +- packages/cli/src/nonInteractive/session.ts | 14 +- packages/cli/src/nonInteractiveCli.test.ts | 1013 ++- packages/cli/src/nonInteractiveCli.ts | 44 +- .../cli/src/remoteInput/RemoteInputWatcher.ts | 2 +- packages/cli/src/serve/fast-path.test.ts | 4 +- packages/cli/src/startup/worktreeStartup.ts | 2 +- packages/cli/src/ui/AppContainer.test.tsx | 122 +- packages/cli/src/ui/AppContainer.tsx | 103 +- .../src/ui/commands/advisor-command.test.ts | 4 +- .../cli/src/ui/commands/advisor-command.ts | 2 +- .../arenaCommand.agentComplete.test.ts | 2 +- packages/cli/src/ui/commands/arenaCommand.ts | 2 +- .../cli/src/ui/commands/btwCommand.test.ts | 2 +- .../cli/src/ui/commands/cdCommand.test.ts | 4 +- packages/cli/src/ui/commands/cdCommand.ts | 2 +- .../cli/src/ui/commands/clearCommand.test.ts | 26 +- packages/cli/src/ui/commands/clearCommand.ts | 6 +- .../src/ui/commands/compressCommand.test.ts | 26 +- .../cli/src/ui/commands/compressCommand.ts | 6 +- .../ui/commands/compressFastCommand.test.ts | 22 +- .../src/ui/commands/compressFastCommand.ts | 6 +- .../src/ui/commands/contextCommand.test.ts | 6 +- .../cli/src/ui/commands/contextCommand.ts | 10 +- .../cli/src/ui/commands/copyCommand.test.ts | 2 +- packages/cli/src/ui/commands/copyCommand.ts | 2 +- .../src/ui/commands/directoryCommand.test.tsx | 6 +- .../cli/src/ui/commands/directoryCommand.tsx | 2 +- .../cli/src/ui/commands/doctorChecks.test.ts | 12 +- packages/cli/src/ui/commands/doctorChecks.ts | 2 +- .../cli/src/ui/commands/doctorCommand.test.ts | 6 +- packages/cli/src/ui/commands/doctorCommand.ts | 2 +- .../cli/src/ui/commands/forkCommand.test.ts | 6 +- packages/cli/src/ui/commands/forkCommand.ts | 5 +- .../src/ui/commands/languageCommand.test.ts | 12 +- .../cli/src/ui/commands/languageCommand.ts | 2 +- .../cli/src/ui/commands/mcpCommand.test.ts | 4 +- .../src/ui/commands/restoreCommand.test.ts | 2 +- .../cli/src/ui/commands/restoreCommand.ts | 2 +- .../src/ui/commands/summaryCommand.test.ts | 4 +- .../cli/src/ui/commands/summaryCommand.ts | 10 +- packages/cli/src/ui/commands/toolsCommand.ts | 4 +- packages/cli/src/ui/commands/types.ts | 2 +- .../cli/src/ui/components/Composer.test.tsx | 2 +- .../ui/components/HistoryItemDisplay.test.tsx | 8 +- .../src/ui/components/HistoryItemDisplay.tsx | 12 +- .../ui/components/IdeTrustChangeDialog.tsx | 3 +- .../src/ui/components/InputPrompt.test.tsx | 20 +- .../cli/src/ui/components/InputPrompt.tsx | 4 +- .../src/ui/components/MainContent.test.tsx | 6 +- .../cli/src/ui/components/MainContent.tsx | 8 +- .../BackgroundTasksDialog.test.tsx | 2 +- .../components/mcp/steps/AuthenticateStep.tsx | 6 +- .../cli/src/ui/contexts/UIStateContext.tsx | 2 +- packages/cli/src/ui/handleAutoUpdate.test.ts | 2 +- packages/cli/src/ui/handleAutoUpdate.ts | 2 +- .../ui/hooks/shellCommandProcessor.test.ts | 14 +- .../cli/src/ui/hooks/shellCommandProcessor.ts | 18 +- .../ui/hooks/slashCommandProcessor.test.ts | 10 +- .../cli/src/ui/hooks/slashCommandProcessor.ts | 2 +- ...tream.test.tsx => use-llm-stream.test.tsx} | 998 +-- .../{useGeminiStream.ts => use-llm-stream.ts} | 314 +- .../cli/src/ui/hooks/useBackgroundTaskView.ts | 2 +- .../cli/src/ui/hooks/useBranchCommand.test.ts | 14 +- packages/cli/src/ui/hooks/useBranchCommand.ts | 4 +- .../cli/src/ui/hooks/useReactToolScheduler.ts | 22 +- .../cli/src/ui/hooks/useResumeCommand.test.ts | 28 +- packages/cli/src/ui/hooks/useResumeCommand.ts | 2 +- .../cli/src/ui/hooks/useToolScheduler.test.ts | 2 +- packages/cli/src/ui/startInteractiveUI.tsx | 2 +- packages/cli/src/ui/types.ts | 22 +- packages/cli/src/ui/utils/MarkdownDisplay.tsx | 2 +- .../cli/src/ui/utils/historyMapping.test.ts | 128 +- .../src/ui/utils/pending-rendered-height.ts | 2 +- packages/cli/src/ui/utils/restoreGoal.ts | 2 +- .../cli/src/ui/utils/todoSnapshot.test.ts | 30 +- packages/cli/src/utils/earlyInputCapture.ts | 2 +- .../cli/src/utils/headlessSafetyWarnings.ts | 4 +- packages/cli/src/utils/startupProfiler.ts | 4 +- .../src/utils/uncaught-exception-handler.ts | 8 +- packages/cli/tsconfig.json | 2 +- .../agents/background-agent-resume.test.ts | 2 +- .../src/agents/background-agent-resume.ts | 6 +- .../core/src/agents/forkedAgent.cache.test.ts | 62 +- packages/core/src/agents/forkedAgent.ts | 12 +- .../src/agents/runtime/agent-core.test.ts | 4 +- .../core/src/agents/runtime/agent-core.ts | 40 +- .../src/agents/runtime/agent-headless.test.ts | 60 +- .../core/src/agents/runtime/agent-headless.ts | 4 +- .../src/agents/runtime/agent-interactive.ts | 4 +- .../core/src/agents/runtime/workflow-stall.ts | 2 +- .../src/config/config-session-env.test.ts | 26 +- .../core/src/config/config.safe-mode.test.ts | 6 +- packages/core/src/config/config.test.ts | 46 +- packages/core/src/config/config.ts | 51 +- .../anthropicContentGenerator.test.ts | 4 +- .../anthropicContentGenerator.ts | 41 +- .../converter.test.ts | 245 +- .../anthropicContentGenerator/converter.ts | 46 +- packages/core/src/core/baseLlmClient.ts | 4 +- packages/core/src/core/client-goal.test.ts | 81 +- packages/core/src/core/client.test.ts | 714 +- packages/core/src/core/client.ts | 128 +- packages/core/src/core/contentGenerator.ts | 6 +- .../core/src/core/coreToolScheduler.test.ts | 124 +- packages/core/src/core/coreToolScheduler.ts | 8 +- .../environmentContext.mcp-subagent.test.ts | 6 +- packages/core/src/core/environmentContext.ts | 4 +- packages/core/src/core/geminiChat.ts | 5715 +--------------- packages/core/src/core/genai-compat.ts | 4 +- .../src/core/goal-turn-integration.test.ts | 10 +- .../{geminiChat.test.ts => llm-chat.test.ts} | 182 +- packages/core/src/core/llm-chat.ts | 5722 +++++++++++++++++ .../index.test.ts | 26 +- .../index.ts | 12 +- .../llm-content-generator.test.ts} | 61 +- .../llm-content-generator.ts} | 20 +- .../loggingContentGenerator.test.ts | 51 +- .../loggingContentGenerator.ts | 14 +- .../core/nonInteractiveToolExecutor.test.ts | 2 +- .../openaiContentGenerator/converter.test.ts | 510 +- .../core/openaiContentGenerator/converter.ts | 66 +- .../openaiContentGenerator/pipeline.test.ts | 556 +- .../core/openaiContentGenerator/pipeline.ts | 24 +- packages/core/src/core/session-recovery.ts | 2 +- .../core/src/core/stream-transport-retry.ts | 2 +- packages/core/src/core/turn-interruption.ts | 2 +- packages/core/src/core/turn.test.ts | 142 +- packages/core/src/core/turn.ts | 218 +- .../extension/extension-runtime-refresh.ts | 2 +- .../src/extension/extensionManager.test.ts | 2 +- packages/core/src/followup/speculation.ts | 10 +- packages/core/src/goals/goalJudge.test.ts | 2 +- packages/core/src/goals/goalJudge.ts | 4 +- .../src/goals/goalLoop.integration.test.ts | 2 +- packages/core/src/index.test.ts | 20 +- packages/core/src/index.ts | 2 +- packages/core/src/memory/refresh.test.ts | 8 +- packages/core/src/memory/refresh.ts | 2 +- packages/core/src/memory/relevanceSelector.ts | 2 +- .../permissions/classifier-transcript.test.ts | 2 +- .../services/backgroundShellRegistry.test.ts | 2 +- .../services/chatCompressionService.test.ts | 46 +- .../src/services/chatCompressionService.ts | 6 +- .../src/services/loopDetectionService.test.ts | 62 +- .../core/src/services/loopDetectionService.ts | 26 +- .../services/memoryDiagnosticsDumper.test.ts | 6 +- .../src/services/memoryDiagnosticsDumper.ts | 6 +- .../services/memoryPressureMonitor.test.ts | 28 +- .../src/services/memoryPressureMonitor.ts | 2 +- .../services/postCompactAttachments.test.ts | 2 +- .../src/services/postCompactAttachments.ts | 2 +- .../src/services/session-writer-lease.test.ts | 6 +- .../core/src/services/sessionRecap.test.ts | 2 +- packages/core/src/services/sessionRecap.ts | 8 +- .../core/src/services/sessionTitle.test.ts | 6 +- packages/core/src/services/sessionTitle.ts | 8 +- .../core/src/services/toolUseSummary.test.ts | 2 +- packages/core/src/services/toolUseSummary.ts | 2 +- .../core/src/skills/skill-manager.test.ts | 2 +- packages/core/src/telemetry/constants.ts | 2 +- .../src/telemetry/detailed-span-attributes.ts | 6 +- .../core/src/telemetry/gen-ai-content.test.ts | 26 +- packages/core/src/telemetry/gen-ai-content.ts | 10 +- .../core/src/telemetry/gen-ai-request.test.ts | 8 +- packages/core/src/telemetry/gen-ai-request.ts | 20 +- packages/core/src/telemetry/loggers.test.ts | 8 +- packages/core/src/telemetry/loggers.ts | 2 +- .../src/telemetry/qwen-logger/qwen-logger.ts | 2 +- packages/core/src/telemetry/types.ts | 4 +- packages/core/src/tools/agent/agent.test.ts | 44 +- packages/core/src/tools/agent/agent.ts | 20 +- .../core/src/tools/agent/fork-subagent.ts | 2 +- packages/core/src/tools/edit.test.ts | 6 +- packages/core/src/tools/enterPlanMode.ts | 6 +- packages/core/src/tools/notebook-edit.test.ts | 2 +- .../src/tools/shell.backgroundStatus.test.ts | 2 +- packages/core/src/tools/shell.test.ts | 2 +- packages/core/src/tools/skill.test.ts | 2 +- packages/core/src/tools/skill.ts | 2 +- packages/core/src/tools/syntheticOutput.ts | 2 +- packages/core/src/tools/tool-registry.ts | 2 +- packages/core/src/tools/tool-search.test.ts | 32 +- packages/core/src/tools/tool-search.ts | 10 +- packages/core/src/tools/write-file.test.ts | 20 +- packages/core/src/utils/btwUtils.ts | 6 +- .../core/src/utils/nextSpeakerChecker.test.ts | 10 +- packages/core/src/utils/nextSpeakerChecker.ts | 6 +- packages/core/src/utils/retry.test.ts | 2 +- packages/core/src/utils/retry.ts | 2 +- packages/core/src/utils/startupEventSink.ts | 2 +- .../sdk-typescript/src/daemon/DaemonClient.ts | 2 +- .../hooks/useStreamingLoadingMetrics.ts | 2 +- scripts/check-serve-fast-path-bundle.js | 7 +- .../serve-fast-path-bundle-check.test.js | 2 +- 229 files changed, 9843 insertions(+), 9808 deletions(-) rename packages/cli/src/{gemini.test.tsx => llm.test.tsx} (99%) rename packages/cli/src/{gemini.tsx => llm.tsx} (99%) rename packages/cli/src/ui/hooks/{useGeminiStream.test.tsx => use-llm-stream.test.tsx} (95%) rename packages/cli/src/ui/hooks/{useGeminiStream.ts => use-llm-stream.ts} (96%) rename packages/core/src/core/{geminiChat.test.ts => llm-chat.test.ts} (99%) create mode 100644 packages/core/src/core/llm-chat.ts rename packages/core/src/core/{geminiContentGenerator => llm-content-generator}/index.test.ts (71%) rename packages/core/src/core/{geminiContentGenerator => llm-content-generator}/index.ts (85%) rename packages/core/src/core/{geminiContentGenerator/geminiContentGenerator.test.ts => llm-content-generator/llm-content-generator.test.ts} (90%) rename packages/core/src/core/{geminiContentGenerator/geminiContentGenerator.ts => llm-content-generator/llm-content-generator.ts} (95%) diff --git a/docs/design/2026-08-22-rename-gemini-fork-residue.md b/docs/design/2026-08-22-rename-gemini-fork-residue.md index 397518c101e..9d3f0480875 100644 --- a/docs/design/2026-08-22-rename-gemini-fork-residue.md +++ b/docs/design/2026-08-22-rename-gemini-fork-residue.md @@ -98,8 +98,8 @@ Non-test files; `gemini-converter.ts` is intentionally NOT renamed (see above). | `packages/core/src/core/geminiContentGenerator/index.ts` | `packages/core/src/core/llm-content-generator/index.ts` | | `packages/core/src/core/geminiRequest.ts` | `packages/core/src/core/llm-request.ts` | -The old `geminiRequest.ts` path remains as a deprecated re-export shim until a -future major release. +The old `geminiRequest.ts` and `geminiChat.ts` paths remain as deprecated +re-export shims until a future major release. ## Phasing @@ -133,6 +133,9 @@ move as one atomic PR. - Stream layer: `useGeminiStream` → `useLlmStream` (`use-llm-stream.ts`), `gemini.tsx` → `llm.tsx`. - Protocol converters: `convert*ToGemini*` / `convertGemini*To*` → `Llm`. +- Deprecated compatibility aliases for the published core classes, event + types, `Config` client access/initialization option, and old chat module + path. ## Risks diff --git a/docs/developers/daemon/20-quickstart-operations.md b/docs/developers/daemon/20-quickstart-operations.md index 890c5463f40..e2c93ba99d7 100644 --- a/docs/developers/daemon/20-quickstart-operations.md +++ b/docs/developers/daemon/20-quickstart-operations.md @@ -225,7 +225,7 @@ qwen serve packages/cli/index.ts main() | v -gemini.tsx main() - parseArguments() +llm.tsx main() - parseArguments() | v (yargs assembly) config/config.ts import { serveCommand } ... @@ -276,7 +276,7 @@ Key facts: - **`createServeApp` only builds; it does not listen.** It returns an `express()` instance with middleware and routes mounted. Ordinary-only embedders may continue to own `app.listen()`. Embedders that use Live/Conversations must bind the actual Node server to the exported app lifecycle before listening and await that lifecycle during shutdown. - **`() => actualPort` is a lazy closure.** `actualPort` is assigned in the `server.listen` callback. The `hostAllowlist` middleware reads it on demand, so ephemeral ports (`--port 0`) still gate the `Host` header correctly. -- **`await blockForever()` is intentional.** If `yargs.parse()` resolves, the CLI top level falls through into the interactive TUI entrypoint (`gemini.tsx`). SIGINT / SIGTERM exit through `runQwenServe`'s `onSignal` path. +- **`await blockForever()` is intentional.** If `yargs.parse()` resolves, the CLI top level falls through into the interactive TUI entrypoint (`llm.tsx`). SIGINT / SIGTERM exit through `runQwenServe`'s `onSignal` path. ## 10. HTTP route file split diff --git a/docs/e2e-tests/2026-05-19-oom-reproduction-report.md b/docs/e2e-tests/2026-05-19-oom-reproduction-report.md index 8716e208f56..fb1456dedfc 100644 --- a/docs/e2e-tests/2026-05-19-oom-reproduction-report.md +++ b/docs/e2e-tests/2026-05-19-oom-reproduction-report.md @@ -54,7 +54,7 @@ history 足够大时会产生峰值放大,需要再用默认 heap 长任务验 ### 关键配置修改 -`packages/core/src/core/geminiChat.ts` 中将 heap-pressure compaction 阈值从 0.7 改为 99.0(使其永远不触发),模拟 #4186 修复前的状态。 +`packages/core/src/core/llm-chat.ts` 中将 heap-pressure compaction 阈值从 0.7 改为 99.0(使其永远不触发),模拟 #4186 修复前的状态。 --- @@ -63,7 +63,7 @@ history 足够大时会产生峰值放大,需要再用默认 heap 长任务验 ### 崩溃时间线 ``` -[21:26:59] #1 RSS:193.6MB Ctx:0% → Read geminiChat.ts (1500 行) +[21:26:59] #1 RSS:193.6MB Ctx:0% → Read llm-chat.ts (1500 行) [21:27:46] #2 RSS:270.4MB Ctx:4.2% → Read agent.ts [21:28:32] #3 RSS:397.5MB Ctx:4.3% → grep + Read 3 个文件 [21:29:18] #4 RSS:452.7MB Ctx:5.7% → Read slashCommandProcessor.ts @@ -363,7 +363,7 @@ sendMessage() SESSION="$1" TASKS=( - "用 Read 工具完整读取 packages/core/src/core/geminiChat.ts" + "用 Read 工具完整读取 packages/core/src/core/llm-chat.ts" "用 Read 工具完整读取 packages/core/src/tools/agent/agent.ts" "用 grep -rn structuredClone packages/core/src 然后 Read 前 3 个文件" "用 Read 完整读取 packages/cli/src/ui/hooks/slashCommandProcessor.ts" @@ -399,7 +399,7 @@ done ```bash # 1. 禁用 heap-pressure safety net -# geminiChat.ts: HEAP_PRESSURE_COMPRESSION_RATIO = 99.0 +# llm-chat.ts: HEAP_PRESSURE_COMPRESSION_RATIO = 99.0 # 2. Build npm run build --workspace=packages/core && npm run build --workspace=packages/cli diff --git a/docs/e2e-tests/worktree-phase-d.md b/docs/e2e-tests/worktree-phase-d.md index 4952ee0678d..c8a277eccca 100644 --- a/docs/e2e-tests/worktree-phase-d.md +++ b/docs/e2e-tests/worktree-phase-d.md @@ -698,7 +698,7 @@ not an implementation issue. **Ready for Phase 7 code review.** | ----------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | Re-attach to existing worktree (G1.b) | `packages/cli/src/startup/worktreeStartup.ts` | Added pre-create check: if dir is a registered worktree on the expected branch, skip create + chdir | | `getRegisteredWorktreeBranch()` helper | `packages/core/src/services/gitWorktreeService.ts` | Probes `git rev-parse --abbrev-ref HEAD` against the candidate path | -| Path normalization before chdir (G2) | `packages/cli/src/gemini.tsx` | Resolves `mcpConfig`, `openaiLoggingDir`, `jsonFile`, `inputFile`, `telemetryOutfile`, `includeDirectories` against launch cwd when `--worktree` is set | +| Path normalization before chdir (G2) | `packages/cli/src/llm.tsx` | Resolves `mcpConfig`, `openaiLoggingDir`, `jsonFile`, `inputFile`, `telemetryOutfile`, `includeDirectories` against launch cwd when `--worktree` is set | | Documentation: yargs flag ordering tip + Limitations update | `docs/users/features/worktree.md` | Quick Start tip + new Limitations bullets (cross-slug, path-arg behavior) | | Unit tests for re-attach | `packages/cli/src/startup/worktreeStartup.test.ts` | Added 2 tests: happy re-attach + "different branch occupies slot" guard | diff --git a/docs/users/features/tool-use-summaries.md b/docs/users/features/tool-use-summaries.md index f0eadc1556a..e469fd30a28 100644 --- a/docs/users/features/tool-use-summaries.md +++ b/docs/users/features/tool-use-summaries.md @@ -124,7 +124,7 @@ Three points that tend to trip up a first read of this feature: 1. **One generation per batch, shared by both display modes.** The fast-model call happens exactly once in `handleCompletedTools` when a tool batch finalizes. Toggling `Ctrl+O` expanded detail mode afterwards does **not** trigger a new call — both the collapsed and the expanded rendering read from the same `tool_use_summary` history entry that was captured the first time. 2. **No backfill on toggle or on session resume.** A `tool_group` that completed before the feature was enabled (or before you flipped the setting on, or in a resumed session — `ChatRecordingService` does not persist summary entries) will never get a label. There is no "sweep existing history" pass. If you turn this setting on mid-session, only _future_ batches will show a label; older groups keep the default rendering with no indicator that a label is missing. -3. **Main-agent batches only.** The trigger lives in the main session's turn loop (`useGeminiStream`), so: +3. **Main-agent batches only.** The trigger lives in the main session's turn loop (`useLlmStream`), so: - ✅ Shell, MCP, file operations, and the `Task` / subagent tool _call itself_ (as it appears in the main batch) are summarized. - ❌ A subagent's **internal** tool batches (run through `packages/core/src/agents/runtime/`) are not summarized. diff --git a/eslint.legacy-filenames.mjs b/eslint.legacy-filenames.mjs index a2839e04beb..dc234a8ad0f 100644 --- a/eslint.legacy-filenames.mjs +++ b/eslint.legacy-filenames.mjs @@ -133,7 +133,6 @@ export const legacyFilenames = [ 'forkedAgent.cache', 'functionHookRunner', 'geminiChat', - 'geminiContentGenerator', 'geminiRequest', 'generateContentResponseUtilities', 'generatedFiles', @@ -441,7 +440,6 @@ export const legacyFilenames = [ 'useFeedbackDialog', 'useFocus', 'useFolderTrust', - 'useGeminiStream', 'useGitBranchName', 'useHistoryManager', 'useHooksDialog', diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index f559eac52a0..02b0af71702 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -10911,7 +10911,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { async generateSessionRecap(sessionId, _context) { // Thin pass-through to `qwen/control/session/ // recap` — the ACP child runs `generateSessionRecap` against the - // session's GeminiClient history and returns `{sessionId, recap}` + // session's LlmClient history and returns `{sessionId, recap}` // where `recap` may be `null` for too-short histories or transient // model failures. The core helper is documented to never throw, // so the only paths that surface as bridge errors are: unknown diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index a68e9ccd0b9..3f8e15a5aa5 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -555,7 +555,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ refreshMemoryInstruction: vi.fn( async (config: { refreshHierarchicalMemory?: () => Promise; - getGeminiClient?: () => + getLlmClient?: () => | { refreshSystemInstruction?: () => Promise } | undefined; }) => { @@ -565,7 +565,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ // Best-effort, matching the real helper. } try { - await config.getGeminiClient?.()?.refreshSystemInstruction?.(); + await config.getLlmClient?.()?.refreshSystemInstruction?.(); } catch { // Best-effort, matching the real helper. } @@ -1647,7 +1647,7 @@ describe('runAcpAgent shutdown cleanup', () => { }); it('writes config startup warnings to stderr for the ACP client log', async () => { - // The ACP path exits gemini.tsx before its startup-warning printing + // The ACP path exits llm.tsx before its startup-warning printing // runs; runAcpAgent must emit config warnings (e.g. the WebSearch // enablement notices) itself or they vanish. (mockConfig as unknown as { getWarnings: () => string[] }).getWarnings = @@ -3962,7 +3962,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { getSessionId: vi.fn().mockReturnValue('test-session-id'), getAuthType: vi.fn().mockReturnValue('api-key'), getAllConfiguredModels: vi.fn().mockReturnValue([]), - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(true), initialize: vi.fn().mockResolvedValue(undefined), waitForMcpReady: vi.fn().mockResolvedValue(undefined), @@ -4347,7 +4347,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { isRestrictiveSandbox: vi.fn().mockReturnValue(false), relocateWorkingDirectory: vi.fn().mockResolvedValue({}), }); - Object.assign(innerConfig.getGeminiClient(), { + Object.assign(innerConfig.getLlmClient(), { addWorkingDirectoryChangedContext: vi.fn().mockResolvedValue(undefined), }); vi.mocked(loadSettings).mockReturnValue(settings); @@ -4740,7 +4740,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { isMcpServerDisabled: vi.fn().mockReturnValue(false), setDisabledTools: vi.fn(), getTargetDir: vi.fn().mockReturnValue('/tmp'), - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(false), setTools: vi.fn().mockResolvedValue(undefined), }), @@ -4931,7 +4931,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { isRestrictiveSandbox: vi.fn().mockReturnValue(false), relocateWorkingDirectory, }); - Object.assign(innerConfig.getGeminiClient(), { + Object.assign(innerConfig.getLlmClient(), { addWorkingDirectoryChangedContext: vi.fn().mockResolvedValue(undefined), }); const { agent, agentPromise } = await bootAcpAgent(); @@ -4977,7 +4977,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { mcpRefreshError: new Error('MCP failed'), }), }); - Object.assign(innerConfig.getGeminiClient(), { + Object.assign(innerConfig.getLlmClient(), { addWorkingDirectoryChangedContext: vi.fn().mockResolvedValue(undefined), }); const { agent, agentPromise } = await bootAcpAgent(); @@ -5035,7 +5035,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { isRestrictiveSandbox: vi.fn().mockReturnValue(false), relocateWorkingDirectory, }); - Object.assign(innerConfig.getGeminiClient(), { + Object.assign(innerConfig.getLlmClient(), { addWorkingDirectoryChangedContext: vi.fn().mockResolvedValue(undefined), }); const { agent, agentPromise } = await bootAcpAgent(); @@ -8298,7 +8298,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { isManagedMemoryAvailable: vi.fn().mockReturnValue(true), getProjectRoot: vi.fn().mockReturnValue('/workspace'), refreshHierarchicalMemory, - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ refreshSystemInstruction, }), }); @@ -8357,7 +8357,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { ...makeInnerConfig(), getSessionId: vi.fn().mockReturnValue('remember-session'), refreshHierarchicalMemory: sessionRefreshHierarchicalMemory, - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(true), initialize: vi.fn().mockResolvedValue(undefined), waitForMcpReady: vi.fn().mockResolvedValue(undefined), @@ -8436,7 +8436,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { ...makeInnerConfig(), getSessionId: vi.fn().mockReturnValue('remember-noop-session'), refreshHierarchicalMemory: sessionRefreshHierarchicalMemory, - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(true), initialize: vi.fn().mockResolvedValue(undefined), waitForMcpReady: vi.fn().mockResolvedValue(undefined), @@ -8515,7 +8515,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { ...makeInnerConfig(), getSessionId: vi.fn().mockReturnValue('remember-fail-session'), refreshHierarchicalMemory: sessionRefreshHierarchicalMemory, - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(true), initialize: vi.fn().mockResolvedValue(undefined), waitForMcpReady: vi.fn().mockResolvedValue(undefined), @@ -9736,7 +9736,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { const collapsed = `review this branch ${'x'.repeat(220)}`; Object.assign(innerConfig, { - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(true), initialize: vi.fn().mockResolvedValue(undefined), waitForMcpReady: vi.fn().mockResolvedValue(undefined), @@ -9809,7 +9809,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { const execute = vi.fn(); const build = vi.fn().mockReturnValue({ execute }); Object.assign(innerConfig, { - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(true), initialize: vi.fn().mockResolvedValue(undefined), waitForMcpReady: vi.fn().mockResolvedValue(undefined), @@ -9880,7 +9880,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { const execute = vi.fn().mockResolvedValue({ llmContent: 'ok' }); const build = vi.fn().mockReturnValue({ execute }); Object.assign(innerConfig, { - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(true), initialize: vi.fn().mockResolvedValue(undefined), waitForMcpReady: vi.fn().mockResolvedValue(undefined), @@ -13926,7 +13926,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); expect(mockConfig.initialize).toHaveBeenCalledWith({ - skipGeminiInitialization: true, + skipLlmInitialization: true, // F2 (#4175 commit 6 review fix — claude-opus-4-7 W119): also // pins that the bootstrap path opts out of MCP discovery (so // bootstrap + per-session don't double-spawn N stdio servers). @@ -14035,7 +14035,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { innerConfig.hasHooksForEvent = vi.fn().mockReturnValue(true); innerConfig.getModel = vi.fn().mockReturnValue('test-model'); innerConfig.getApprovalMode = vi.fn().mockReturnValue('default'); - innerConfig.getGeminiClient = vi.fn().mockReturnValue({ + innerConfig.getLlmClient = vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(false), initialize, }); @@ -14056,7 +14056,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agent.newSession({ cwd: '/tmp', mcpServers: [] }); expect(mockConfig.initialize).toHaveBeenCalledWith({ - skipGeminiInitialization: true, + skipLlmInitialization: true, // F2 (#4175 commit 6 review fix — claude-opus-4-7 W119): also // pins that the bootstrap path opts out of MCP discovery (so // bootstrap + per-session don't double-spawn N stdio servers). @@ -14102,7 +14102,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); - it('does not directly re-fire SessionStart for subsequent ACP sessions when GeminiClient is already initialized', async () => { + it('does not directly re-fire SessionStart for subsequent ACP sessions when LlmClient is already initialized', async () => { const innerConfig = await setupSessionMocks( 'session-followup-session-start', ); @@ -14115,7 +14115,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { innerConfig.hasHooksForEvent = vi.fn().mockReturnValue(true); innerConfig.getModel = vi.fn().mockReturnValue('test-model'); innerConfig.getApprovalMode = vi.fn().mockReturnValue('default'); - innerConfig.getGeminiClient = vi + innerConfig.getLlmClient = vi .fn() .mockReturnValueOnce({ isInitialized: vi.fn().mockReturnValue(false), @@ -14176,7 +14176,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { innerConfigA.hasHooksForEvent = vi .fn() .mockImplementation((event: string) => event === 'SessionEnd'); - innerConfigA.getGeminiClient = vi.fn().mockReturnValue({ + innerConfigA.getLlmClient = vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(false), initialize: vi.fn().mockResolvedValue(undefined), }); @@ -14192,7 +14192,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { innerConfigB.hasHooksForEvent = vi .fn() .mockImplementation((event: string) => event === 'SessionEnd'); - innerConfigB.getGeminiClient = vi.fn().mockReturnValue({ + innerConfigB.getLlmClient = vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(false), initialize: vi.fn().mockResolvedValue(undefined), }); @@ -14919,7 +14919,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { // The warning must list both failed servers and mention "Warning:" // exactly like the top-level path and the other non-interactive - // entry points (`gemini.tsx`, `session.ts`). + // entry points (`llm.tsx`, `session.ts`). await vi.waitFor(() => { const matchingWrite = stderrWrite.mock.calls.find( ([msg]) => @@ -15690,7 +15690,7 @@ describe('QwenAgent extMethod renameSession routing', () => { getSessionId: vi.fn().mockReturnValue(liveSessionId), getAuthType: vi.fn().mockReturnValue('api-key'), getAllConfiguredModels: vi.fn().mockReturnValue([]), - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(true), initialize: vi.fn().mockResolvedValue(undefined), waitForMcpReady: vi.fn().mockResolvedValue(undefined), @@ -17201,7 +17201,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { getSessionId: vi.fn().mockReturnValue('persisted-1'), getAuthType: vi.fn().mockReturnValue('api-key'), getAllConfiguredModels: vi.fn().mockReturnValue([]), - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(true), initialize: vi.fn().mockResolvedValue(undefined), waitForMcpReady: vi.fn().mockResolvedValue(undefined), @@ -20229,7 +20229,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { configOptions: expect.anything(), }); // resume semantic: model context is restored internally via - // geminiClient.initialize(), but UI replay is NOT triggered — + // llmClient.initialize(), but UI replay is NOT triggered — // the SSE stream stays clean for clients that already have the // history rendered. expect(lastSessionMock?.replayHistory).not.toHaveBeenCalled(); @@ -21209,7 +21209,7 @@ describe('sessionLanguage multi-session propagation', () => { getSessionId: vi.fn().mockReturnValue('sid'), getAuthType: vi.fn().mockReturnValue('api-key'), getAllConfiguredModels: vi.fn().mockReturnValue([]), - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(true), initialize: vi.fn().mockResolvedValue(undefined), waitForMcpReady: vi.fn().mockResolvedValue(undefined), @@ -21332,9 +21332,9 @@ describe('sessionLanguage multi-session propagation', () => { expect(cfgC.refreshHierarchicalMemory).toHaveBeenCalled(); // All sessions' system instruction refreshed - expect(cfgA.getGeminiClient().refreshSystemInstruction).toHaveBeenCalled(); - expect(cfgB.getGeminiClient().refreshSystemInstruction).toHaveBeenCalled(); - expect(cfgC.getGeminiClient().refreshSystemInstruction).toHaveBeenCalled(); + expect(cfgA.getLlmClient().refreshSystemInstruction).toHaveBeenCalled(); + expect(cfgB.getLlmClient().refreshSystemInstruction).toHaveBeenCalled(); + expect(cfgC.getLlmClient().refreshSystemInstruction).toHaveBeenCalled(); // Session C registered the global path expect(cfgC.setOutputLanguageFilePath).toHaveBeenCalled(); @@ -21510,9 +21510,7 @@ describe('sessionLanguage multi-session propagation', () => { // Both sessions still refreshed despite cfgFail's write failure expect(cfgOk.refreshHierarchicalMemory).toHaveBeenCalled(); expect(cfgFail.refreshHierarchicalMemory).toHaveBeenCalled(); - expect( - cfgFail.getGeminiClient().refreshSystemInstruction, - ).toHaveBeenCalled(); + expect(cfgFail.getLlmClient().refreshSystemInstruction).toHaveBeenCalled(); mockConnectionState.resolve(); await agentPromise; @@ -21893,7 +21891,7 @@ describe('sessionLanguage multi-session propagation', () => { refreshHierarchicalMemory, }); const refreshSystemInstruction = vi.mocked( - cfg.getGeminiClient().refreshSystemInstruction, + cfg.getLlmClient().refreshSystemInstruction, ); const sendAvailableCommandsUpdate = vi.fn().mockResolvedValue(undefined); @@ -22057,9 +22055,7 @@ describe('sessionLanguage multi-session propagation', () => { expect(skillManager.refreshCache).not.toHaveBeenCalled(); expect(extensionManager.refreshTools).toHaveBeenCalledOnce(); expect(cfg.refreshHierarchicalMemory).not.toHaveBeenCalled(); - expect( - cfg.getGeminiClient().refreshSystemInstruction, - ).toHaveBeenCalledOnce(); + expect(cfg.getLlmClient().refreshSystemInstruction).toHaveBeenCalledOnce(); expect(sendAvailableCommandsUpdate).toHaveBeenCalledOnce(); mockConnectionState.resolve(); @@ -22124,9 +22120,7 @@ describe('sessionLanguage multi-session propagation', () => { expect(skillManager.refreshCache).not.toHaveBeenCalled(); expect(extensionManager.refreshTools).toHaveBeenCalledOnce(); expect(cfg.refreshHierarchicalMemory).not.toHaveBeenCalled(); - expect( - cfg.getGeminiClient().refreshSystemInstruction, - ).toHaveBeenCalledOnce(); + expect(cfg.getLlmClient().refreshSystemInstruction).toHaveBeenCalledOnce(); expect(sendAvailableCommandsUpdate).toHaveBeenCalledOnce(); mockConnectionState.resolve(); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index c6e2c5e21c6..96bcad5b9a5 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -2651,7 +2651,7 @@ export async function runAcpAgent( beginAcpBootstrapConfigProfiling(); try { await config.initialize({ - skipGeminiInitialization: true, + skipLlmInitialization: true, // Bootstrap skips MCP discovery — each session runs its own // pool-routed discovery, so bootstrap-level spawns would be // redundant subprocess leaks (W119). @@ -2663,7 +2663,7 @@ export async function runAcpAgent( } finally { endAcpBootstrapConfigProfiling(); } - // The ACP path exits gemini.tsx before its startup-warning printing runs, + // The ACP path exits llm.tsx before its startup-warning printing runs, // so config warnings (including initialize-time ones like the WebSearch // enablement notice) would otherwise vanish. stderr lands in the client's // logs without interfering with the ACP protocol on stdout. @@ -3512,9 +3512,9 @@ class QwenAgent implements Agent { const registry = config.getToolRegistry(); if (operation === 'discover') { await registry?.discoverToolsForServer(serverName); - const geminiClient = config.getGeminiClient?.(); - if (geminiClient?.isInitialized?.()) { - await geminiClient.setTools?.(); + const llmClient = config.getLlmClient?.(); + if (llmClient?.isInitialized?.()) { + await llmClient.setTools?.(); } } else if (operation === 'disable') { await registry?.disableMcpServer(serverName); @@ -3602,7 +3602,7 @@ class QwenAgent implements Agent { config.setMcpTransportPool(this.mcpPool); try { await config.initialize({ - skipGeminiInitialization: true, + skipLlmInitialization: true, skipFileCheckpointing: true, skipHooks: true, skipSkillManager: true, @@ -3753,9 +3753,9 @@ class QwenAgent implements Agent { } await Promise.all( this.getLiveMcpConfigs(serverName).map(async (config) => { - const geminiClient = config.getGeminiClient?.(); - if (geminiClient?.isInitialized?.()) { - await geminiClient.setTools?.(); + const llmClient = config.getLlmClient?.(); + if (llmClient?.isInitialized?.()) { + await llmClient.setTools?.(); } }), ); @@ -8651,9 +8651,9 @@ class QwenAgent implements Agent { }); await Promise.all( this.getLiveMcpConfigs(serverName).map(async (liveConfig) => { - const geminiClient = liveConfig.getGeminiClient?.(); - if (geminiClient?.isInitialized?.()) { - await geminiClient.setTools?.(); + const llmClient = liveConfig.getLlmClient?.(); + if (llmClient?.isInitialized?.()) { + await llmClient.setTools?.(); } }), ); @@ -9741,7 +9741,7 @@ class QwenAgent implements Agent { try { await config - .getGeminiClient() + .getLlmClient() ?.addWorkingDirectoryChangedContext( settledPreviousCwd, canonicalPath, @@ -9911,7 +9911,7 @@ class QwenAgent implements Agent { ); } await cfg.refreshHierarchicalMemory(); - await cfg.getGeminiClient()?.refreshSystemInstruction(); + await cfg.getLlmClient()?.refreshSystemInstruction(); }), ); const failedCount = results.filter( @@ -10107,7 +10107,7 @@ class QwenAgent implements Agent { let hasHistory = false; try { hasHistory = - (config.getGeminiClient().getHistoryShallow() ?? []).length > 0; + (config.getLlmClient().getHistoryShallow() ?? []).length > 0; } catch (error) { debugLogger.debug('Failed to read history before /fork:', error); } @@ -10148,7 +10148,7 @@ class QwenAgent implements Agent { } try { - config.getGeminiClient().addHistory({ + config.getLlmClient().addHistory({ role: 'user', parts: [ { @@ -10181,10 +10181,10 @@ class QwenAgent implements Agent { } const session = this.sessionOrThrow(sessionId); const config = session.getConfig(); - const geminiClient = config.getGeminiClient()!; + const llmClient = config.getLlmClient()!; const outputText = typeof params['output'] === 'string' ? params['output'] : ''; - geminiClient.addHistory({ + llmClient.addHistory({ role: 'user', parts: [ { @@ -10645,8 +10645,7 @@ class QwenAgent implements Agent { ); } await runRefresh( - async () => - await config.getGeminiClient()?.refreshSystemInstruction(), + async () => await config.getLlmClient()?.refreshSystemInstruction(), ); await runRefresh( async () => await session.sendAvailableCommandsUpdate(), @@ -11525,7 +11524,7 @@ class QwenAgent implements Agent { ); } try { - await config.getGeminiClient()?.refreshSystemInstruction(); + await config.getLlmClient()?.refreshSystemInstruction(); } catch (err) { debugLogger.warn( `reload: refreshSystemInstruction failed for session ${id}: ${err}`, @@ -12133,11 +12132,11 @@ class QwenAgent implements Agent { ): Promise { this.assertManagedSessionAdmission(); const sessionId = normalizeSessionIdForLookup(config.getSessionId()); - const geminiClient = config.getGeminiClient(); - const needsInitialize = !geminiClient.isInitialized(); + const llmClient = config.getLlmClient(); + const needsInitialize = !llmClient.isInitialized(); if (needsInitialize) { - await geminiClient.initialize(); + await llmClient.initialize(); } this.assertManagedSessionAdmission(); diff --git a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts index b7199f5dc83..9fab2150d42 100644 --- a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts @@ -357,7 +357,7 @@ describe('QwenAgent loadSession — Phase C worktree context restore', () => { getSessionId: vi.fn().mockReturnValue(SESSION_ID), getAuthType: vi.fn().mockReturnValue('api-key'), getAllConfiguredModels: vi.fn().mockReturnValue([]), - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(true), initialize: vi.fn().mockResolvedValue(undefined), waitForMcpReady: vi.fn().mockResolvedValue(undefined), diff --git a/packages/cli/src/acp-integration/generation.ts b/packages/cli/src/acp-integration/generation.ts index 0a5b3416efa..c7547514d68 100644 --- a/packages/cli/src/acp-integration/generation.ts +++ b/packages/cli/src/acp-integration/generation.ts @@ -6,7 +6,7 @@ /** * Stateless, tool-free generation for the daemon request-scoped SSE endpoint. - * It deliberately bypasses GeminiChat so neither history nor recording is + * It deliberately bypasses LlmChat so neither history nor recording is * read or mutated. */ import { getResponseText, type Config } from '@qwen-code/qwen-code-core'; diff --git a/packages/cli/src/acp-integration/session/Session.review-lease.test.ts b/packages/cli/src/acp-integration/session/Session.review-lease.test.ts index 1f7345b2a21..8aa498a4765 100644 --- a/packages/cli/src/acp-integration/session/Session.review-lease.test.ts +++ b/packages/cli/src/acp-integration/session/Session.review-lease.test.ts @@ -21,7 +21,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { Session } from './Session.js'; -import type { Config, GeminiChat } from '@qwen-code/qwen-code-core'; +import type { Config, LlmChat } from '@qwen-code/qwen-code-core'; import { ApprovalMode, AuthType, @@ -64,7 +64,7 @@ describe('Session review-worktree lease sweep', () => { /** promptIdContext store observed inside each model send. */ let observedPromptIds: Array; - let mockChat: GeminiChat; + let mockChat: LlmChat; let mockConfig: Config; let mockClient: AgentSideConnection; let mockSettings: LoadedSettings; @@ -83,9 +83,9 @@ describe('Session review-worktree lease sweep', () => { setHistory: vi.fn(), truncateHistory: vi.fn(), stripThoughtsFromHistory: vi.fn(), - } as unknown as GeminiChat; + } as unknown as LlmChat; - const mockGeminiClient = { + const mockLlmClient = { getChat: vi.fn().mockReturnValue(mockChat), tryCompressChat: vi.fn().mockResolvedValue({ originalTokenCount: 0, @@ -143,7 +143,7 @@ describe('Session review-worktree lease sweep', () => { getAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI), isCronEnabled: vi.fn().mockReturnValue(false), getSessionTokenLimit: vi.fn().mockReturnValue(0), - getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient), + getLlmClient: vi.fn().mockReturnValue(mockLlmClient), getDisableAllHooks: vi.fn().mockReturnValue(true), hasHooksForEvent: vi.fn().mockReturnValue(false), getMessageBus: vi.fn().mockReturnValue(undefined), diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 090ae123347..33d454bb2dd 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -32,7 +32,7 @@ import type { ChatRecord, Config, Extension, - GeminiChat, + LlmChat, } from '@qwen-code/qwen-code-core'; import { ApprovalMode, @@ -411,7 +411,7 @@ function expectCompressBeforeSend( } describe('Session', () => { - let mockChat: GeminiChat; + let mockChat: LlmChat; let mockConfig: Config; let mockClient: AgentSideConnection; let mockSettings: LoadedSettings; @@ -447,7 +447,7 @@ describe('Session', () => { restoreFromSnapshots: ReturnType; rewind: ReturnType; }; - let mockGeminiClient: { + let mockLlmClient: { getChat: ReturnType; isInitialized: ReturnType; refreshSystemInstruction: ReturnType; @@ -649,8 +649,8 @@ describe('Session', () => { stripThoughtsFromHistory: vi.fn(), stripOrphanedUserEntriesFromHistory: vi.fn().mockReturnValue([]), setTools: vi.fn(), - } as unknown as GeminiChat; - mockGeminiClient = { + } as unknown as LlmChat; + mockLlmClient = { getChat: vi.fn().mockReturnValue(mockChat), isInitialized: vi.fn().mockReturnValue(true), refreshSystemInstruction: vi.fn().mockResolvedValue(undefined), @@ -837,7 +837,7 @@ describe('Session', () => { // Mirrors the resolved settings default (cli/config.ts passes // `skipLoopDetection ?? true`): heuristics off unless a test opts in. getSkipLoopDetection: vi.fn().mockReturnValue(true), - getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient), + getLlmClient: vi.fn().mockReturnValue(mockLlmClient), getGoalRuntime: vi.fn().mockReturnValue(mockGoalRuntime), getGoalRuntimeReady: vi.fn().mockResolvedValue(mockGoalRuntime), getGoalRuntimePrepared: vi.fn().mockResolvedValue(mockGoalRuntime), @@ -914,11 +914,11 @@ describe('Session', () => { core.Storage.setRuntimeBaseDir(null); // Clear session reference to allow garbage collection session = undefined as unknown as Session; - mockChat = undefined as unknown as GeminiChat; + mockChat = undefined as unknown as LlmChat; mockConfig = undefined as unknown as Config; mockClient = undefined as unknown as AgentSideConnection; mockSettings = undefined as unknown as LoadedSettings; - mockGeminiClient = undefined as unknown as typeof mockGeminiClient; + mockLlmClient = undefined as unknown as typeof mockLlmClient; mockToolRegistry = undefined as unknown as typeof mockToolRegistry; vi.restoreAllMocks(); vi.clearAllTimers(); @@ -1986,7 +1986,7 @@ describe('Session', () => { 'qwen/control/live/speak-to-user', { callerSessionId: 'test-session-id', message: '测试语音' }, ); - expect(mockGeminiClient.setTools).toHaveBeenCalledOnce(); + expect(mockLlmClient.setTools).toHaveBeenCalledOnce(); } finally { await fs.unlink(screenshotPath).catch(() => undefined); } @@ -2633,7 +2633,7 @@ describe('Session', () => { }); it('rejects when the gemini client is not initialized', async () => { - vi.mocked(mockGeminiClient.isInitialized).mockReturnValue(false); + vi.mocked(mockLlmClient.isInitialized).mockReturnValue(false); const promptSpy = vi .spyOn(session, 'prompt') .mockResolvedValue({ stopReason: 'end_turn' }); @@ -2652,7 +2652,7 @@ describe('Session', () => { // Force the continuation send to fail NON-cancelled (session token limit) // so it hits the `!responseStream` branch — the data-loss window. mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(100); - mockGeminiClient.tryCompressChat.mockResolvedValue({ + mockLlmClient.tryCompressChat.mockResolvedValue({ originalTokenCount: 999, newTokenCount: 999, compressionStatus: core.CompressionStatus.NOOP, @@ -6499,8 +6499,8 @@ describe('Session', () => { }); it('fires MessageDisplay with cumulative non-thought text and is_final on the ACP prompt path', async () => { - // Regression: the ACP surface consumes GeminiChat's stream directly - // (never entering GeminiClient.sendMessageStream), so it must fire the + // Regression: the ACP surface consumes LlmChat's stream directly + // (never entering LlmClient.sendMessageStream), so it must fire the // MessageDisplay hook itself — without this, an IDE/daemon client sees // the hook advertised but never receives an event. const messageBus = { request: vi.fn().mockResolvedValue({}) }; @@ -7334,7 +7334,7 @@ describe('Session', () => { const notificationCompression = { signal: undefined as AbortSignal | undefined, }; - mockGeminiClient.tryCompressChat = vi + mockLlmClient.tryCompressChat = vi .fn() .mockResolvedValueOnce({ originalTokenCount: 0, @@ -7379,7 +7379,7 @@ describe('Session', () => { }); await vi.waitFor(() => { - expect(mockGeminiClient.tryCompressChat).toHaveBeenCalledTimes(2); + expect(mockLlmClient.tryCompressChat).toHaveBeenCalledTimes(2); }); await session.cancelPendingPrompt(); @@ -7404,7 +7404,7 @@ describe('Session', () => { compressionStatus: core.CompressionStatus.NOOP, }; let notificationSignal: AbortSignal | undefined; - mockGeminiClient.tryCompressChat = vi + mockLlmClient.tryCompressChat = vi .fn() .mockResolvedValueOnce(noopCompression) .mockImplementationOnce( @@ -7461,7 +7461,7 @@ describe('Session', () => { compressionStatus: core.CompressionStatus.NOOP, }; let notificationSignal: AbortSignal | undefined; - mockGeminiClient.tryCompressChat = vi + mockLlmClient.tryCompressChat = vi .fn() .mockImplementationOnce( async (_promptId: string, _force: boolean, signal: AbortSignal) => { @@ -7585,15 +7585,14 @@ describe('Session', () => { expect(mockToolRegistry.pinDeferredToolReveal).toHaveBeenCalledWith( 'create_sub_session', ); - expect(mockGeminiClient.setTools).toHaveBeenCalledTimes(1); + expect(mockLlmClient.setTools).toHaveBeenCalledTimes(1); // Order is load-bearing: reveal before the declaration refresh, both // after the registration. const registerOrder = mockToolRegistry.registerTool.mock.invocationCallOrder[0]; const revealOrder = mockToolRegistry.revealDeferredTool.mock.invocationCallOrder[0]; - const setToolsOrder = - mockGeminiClient.setTools.mock.invocationCallOrder[0]; + const setToolsOrder = mockLlmClient.setTools.mock.invocationCallOrder[0]; expect(registerOrder).toBeLessThan(revealOrder); expect(revealOrder).toBeLessThan(setToolsOrder); }); @@ -7609,7 +7608,7 @@ describe('Session', () => { await registerCreateSubSessionTool(mockConfig); expect(mockToolRegistry.registerTool).not.toHaveBeenCalled(); - expect(mockGeminiClient.setTools).not.toHaveBeenCalled(); + expect(mockLlmClient.setTools).not.toHaveBeenCalled(); }); it('skips create_sub_session when the permission manager disables it', async () => { @@ -8172,7 +8171,7 @@ describe('Session', () => { expect(mockChatRecordingService.recordUserMessage).toHaveBeenCalledWith( '3', ); - expect(mockGeminiClient.tryCompressChat).toHaveBeenCalledWith( + expect(mockLlmClient.tryCompressChat).toHaveBeenCalledWith( 'test-session-id########3', false, expect.any(AbortSignal), @@ -8678,7 +8677,7 @@ describe('Session', () => { chunk.includes('Routing this image turn'), ), ).toBe(true); - expect(mockGeminiClient.tryCompressChat).not.toHaveBeenCalled(); + expect(mockLlmClient.tryCompressChat).not.toHaveBeenCalled(); await session.prompt({ sessionId: 'test-session-id', @@ -8690,7 +8689,7 @@ describe('Session', () => { expect.any(Object), expect.any(String), ); - expect(mockGeminiClient.tryCompressChat).toHaveBeenCalledOnce(); + expect(mockLlmClient.tryCompressChat).toHaveBeenCalledOnce(); }); it('clamps full-turn images before selecting the ACP route', async () => { @@ -11816,7 +11815,7 @@ describe('Session', () => { prompt: [{ type: 'text', text: 'hello' }], }); - expect(mockGeminiClient.tryCompressChat).toHaveBeenCalledWith( + expect(mockLlmClient.tryCompressChat).toHaveBeenCalledWith( 'test-session-id########1', false, expect.any(AbortSignal), @@ -11826,7 +11825,7 @@ describe('Session', () => { typeof vi.fn >; expectCompressBeforeSend( - mockGeminiClient.tryCompressChat, + mockLlmClient.tryCompressChat, sendMessageStream, 0, ); @@ -11839,13 +11838,13 @@ describe('Session', () => { getHistory: vi.fn().mockReturnValue([]), getHistoryShallow: vi.fn().mockReturnValue([]), getLastModelMessageText: vi.fn().mockReturnValue(''), - } as unknown as GeminiChat; + } as unknown as LlmChat; mockChat.sendMessageStream = vi .fn() .mockResolvedValue(createEmptyStream()); - mockGeminiClient.tryCompressChat.mockImplementation(async () => { - mockGeminiClient.getChat.mockReturnValue(compressedChat); + mockLlmClient.tryCompressChat.mockImplementation(async () => { + mockLlmClient.getChat.mockReturnValue(compressedChat); return { originalTokenCount: 1000, newTokenCount: 200, @@ -11870,7 +11869,7 @@ describe('Session', () => { }); it('emits an ACP-visible update when automatic compression succeeds', async () => { - mockGeminiClient.tryCompressChat.mockResolvedValueOnce({ + mockLlmClient.tryCompressChat.mockResolvedValueOnce({ originalTokenCount: 1200, newTokenCount: 450, compressionStatus: core.CompressionStatus.COMPRESSED, @@ -11899,7 +11898,7 @@ describe('Session', () => { }); it('labels the notice as screenshot-triggered when triggerReason is image_overflow', async () => { - mockGeminiClient.tryCompressChat.mockResolvedValueOnce({ + mockLlmClient.tryCompressChat.mockResolvedValueOnce({ originalTokenCount: 1200, newTokenCount: 450, compressionStatus: core.CompressionStatus.COMPRESSED, @@ -11929,7 +11928,7 @@ describe('Session', () => { }); it('continues sending when automatic compression fails', async () => { - mockGeminiClient.tryCompressChat.mockRejectedValueOnce( + mockLlmClient.tryCompressChat.mockRejectedValueOnce( new Error('compression rate limited'), ); mockChat.sendMessageStream = vi @@ -11941,7 +11940,7 @@ describe('Session', () => { prompt: [{ type: 'text', text: 'hello' }], }); - expect(mockGeminiClient.tryCompressChat).toHaveBeenCalledWith( + expect(mockLlmClient.tryCompressChat).toHaveBeenCalledWith( 'test-session-id########1', false, expect.any(AbortSignal), @@ -11962,7 +11961,7 @@ describe('Session', () => { core.uiTelemetryService, 'getLastPromptTokenCount', ).mockReturnValue(101); - mockGeminiClient.tryCompressChat.mockRejectedValueOnce( + mockLlmClient.tryCompressChat.mockRejectedValueOnce( new Error('compression rate limited'), ); mockChat.sendMessageStream = vi @@ -11991,7 +11990,7 @@ describe('Session', () => { it('returns cancelled when automatic compression is aborted', async () => { mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(100); - mockGeminiClient.tryCompressChat.mockImplementation( + mockLlmClient.tryCompressChat.mockImplementation( async (_promptId: string, _force: boolean, signal: AbortSignal) => new Promise((_, reject) => { signal.addEventListener('abort', () => { @@ -12010,7 +12009,7 @@ describe('Session', () => { prompt: [{ type: 'text', text: 'hello' }], }); await vi.waitFor(() => { - expect(mockGeminiClient.tryCompressChat).toHaveBeenCalled(); + expect(mockLlmClient.tryCompressChat).toHaveBeenCalled(); }); await session.cancelPendingPrompt(); @@ -12040,7 +12039,7 @@ describe('Session', () => { it('surfaces an automatic compression AbortError without an aborted signal', async () => { const error = new Error('compression transport aborted unexpectedly'); error.name = 'AbortError'; - mockGeminiClient.tryCompressChat.mockRejectedValueOnce(error); + mockLlmClient.tryCompressChat.mockRejectedValueOnce(error); mockChat.sendMessageStream = vi .fn() .mockResolvedValue(createEmptyStream()); @@ -12061,7 +12060,7 @@ describe('Session', () => { core.uiTelemetryService, 'getLastPromptTokenCount', ).mockReturnValue(999); - mockGeminiClient.tryCompressChat.mockResolvedValueOnce({ + mockLlmClient.tryCompressChat.mockResolvedValueOnce({ originalTokenCount: 50, newTokenCount: 50, compressionStatus: core.CompressionStatus.NOOP, @@ -12080,7 +12079,7 @@ describe('Session', () => { it('falls back to the previous prompt token count when compression returns zero token info', async () => { mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(100); - mockGeminiClient.tryCompressChat.mockResolvedValue({ + mockLlmClient.tryCompressChat.mockResolvedValue({ originalTokenCount: 0, newTokenCount: 0, compressionStatus: core.CompressionStatus.NOOP, @@ -12120,7 +12119,7 @@ describe('Session', () => { it('falls back to the previous prompt token count when compressed token info is zero', async () => { mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(100); - mockGeminiClient.tryCompressChat + mockLlmClient.tryCompressChat .mockResolvedValueOnce({ originalTokenCount: 50, newTokenCount: 50, @@ -12166,7 +12165,7 @@ describe('Session', () => { it('records prompt token count instead of total token count for later session-limit checks', async () => { mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(100); - mockGeminiClient.tryCompressChat + mockLlmClient.tryCompressChat .mockResolvedValueOnce({ originalTokenCount: 0, newTokenCount: 0, @@ -12213,9 +12212,9 @@ describe('Session', () => { getHistory: vi.fn().mockReturnValue([]), getHistoryShallow: vi.fn().mockReturnValue([]), getLastModelMessageText: vi.fn().mockReturnValue(''), - } as unknown as GeminiChat; + } as unknown as LlmChat; mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(100); - mockGeminiClient.tryCompressChat + mockLlmClient.tryCompressChat .mockResolvedValueOnce({ originalTokenCount: 50, newTokenCount: 50, @@ -12243,7 +12242,7 @@ describe('Session', () => { }), ).resolves.toEqual({ stopReason: 'end_turn' }); - mockGeminiClient.getChat.mockReturnValue(clearedChat); + mockLlmClient.getChat.mockReturnValue(clearedChat); await expect( session.prompt({ @@ -12256,7 +12255,7 @@ describe('Session', () => { }); it('continues sending when the compression notification fails', async () => { - mockGeminiClient.tryCompressChat.mockResolvedValueOnce({ + mockLlmClient.tryCompressChat.mockResolvedValueOnce({ originalTokenCount: 1200, newTokenCount: 450, compressionStatus: core.CompressionStatus.COMPRESSED, @@ -12279,7 +12278,7 @@ describe('Session', () => { it('stops before sending when the compressed prompt exceeds the session token limit', async () => { mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(100); - mockGeminiClient.tryCompressChat.mockResolvedValueOnce({ + mockLlmClient.tryCompressChat.mockResolvedValueOnce({ originalTokenCount: 1200, newTokenCount: 101, compressionStatus: core.CompressionStatus.COMPRESSED, @@ -12295,7 +12294,7 @@ describe('Session', () => { }), ).resolves.toEqual({ stopReason: 'max_tokens' }); - expect(mockGeminiClient.tryCompressChat).toHaveBeenCalled(); + expect(mockLlmClient.tryCompressChat).toHaveBeenCalled(); expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); expect(mockChat.addHistory).not.toHaveBeenCalled(); expect(mockClient.sessionUpdate).not.toHaveBeenCalledWith({ @@ -12326,7 +12325,7 @@ describe('Session', () => { it('stops without throwing when the token-limit diagnostic fails', async () => { mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(100); - mockGeminiClient.tryCompressChat.mockResolvedValueOnce({ + mockLlmClient.tryCompressChat.mockResolvedValueOnce({ originalTokenCount: 101, newTokenCount: 101, compressionStatus: core.CompressionStatus.NOOP, @@ -12395,8 +12394,8 @@ describe('Session', () => { }); expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); - expect(mockGeminiClient.tryCompressChat).toHaveBeenCalledTimes(2); - expect(mockGeminiClient.tryCompressChat).toHaveBeenNthCalledWith( + expect(mockLlmClient.tryCompressChat).toHaveBeenCalledTimes(2); + expect(mockLlmClient.tryCompressChat).toHaveBeenNthCalledWith( 2, 'test-session-id########1', false, @@ -12407,7 +12406,7 @@ describe('Session', () => { typeof vi.fn >; expectCompressBeforeSend( - mockGeminiClient.tryCompressChat, + mockLlmClient.tryCompressChat, sendMessageStream, 1, ); @@ -14341,7 +14340,7 @@ describe('Session', () => { mockToolRegistry.getTool.mockReturnValue(tool); mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO); mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(100); - mockGeminiClient.tryCompressChat + mockLlmClient.tryCompressChat .mockResolvedValueOnce({ originalTokenCount: 50, newTokenCount: 50, @@ -14380,8 +14379,8 @@ describe('Session', () => { ).resolves.toEqual({ stopReason: 'max_tokens' }); expect(executeSpy).toHaveBeenCalledTimes(1); - expect(mockGeminiClient.tryCompressChat).toHaveBeenCalledTimes(2); - expect(mockGeminiClient.tryCompressChat).toHaveBeenNthCalledWith( + expect(mockLlmClient.tryCompressChat).toHaveBeenCalledTimes(2); + expect(mockLlmClient.tryCompressChat).toHaveBeenNthCalledWith( 2, 'test-session-id########1', false, @@ -14453,7 +14452,7 @@ describe('Session', () => { }); expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); - expect(mockGeminiClient.tryCompressChat).toHaveBeenNthCalledWith( + expect(mockLlmClient.tryCompressChat).toHaveBeenNthCalledWith( 2, 'test-session-id########1_stop_hook_1', false, @@ -14464,7 +14463,7 @@ describe('Session', () => { typeof vi.fn >; expectCompressBeforeSend( - mockGeminiClient.tryCompressChat, + mockLlmClient.tryCompressChat, sendMessageStream, 1, ); @@ -14518,14 +14517,14 @@ describe('Session', () => { }); expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(3); - expect(mockGeminiClient.tryCompressChat).toHaveBeenCalledTimes(2); - expect(mockGeminiClient.tryCompressChat).toHaveBeenNthCalledWith( + expect(mockLlmClient.tryCompressChat).toHaveBeenCalledTimes(2); + expect(mockLlmClient.tryCompressChat).toHaveBeenNthCalledWith( 2, 'test-session-id########1_stop_hook_1', false, expect.any(AbortSignal), ); - expect(mockGeminiClient.tryCompressChat).not.toHaveBeenCalledWith( + expect(mockLlmClient.tryCompressChat).not.toHaveBeenCalledWith( 'test-session-id########1_stop_hook_2', false, expect.any(AbortSignal), @@ -14564,7 +14563,7 @@ describe('Session', () => { .fn() .mockImplementation((eventName: string) => eventName === 'Stop'); mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(100); - mockGeminiClient.tryCompressChat + mockLlmClient.tryCompressChat .mockResolvedValueOnce({ originalTokenCount: 50, newTokenCount: 50, @@ -14606,8 +14605,8 @@ describe('Session', () => { }), ).resolves.toEqual({ stopReason: 'max_tokens' }); - expect(mockGeminiClient.tryCompressChat).toHaveBeenCalledTimes(2); - expect(mockGeminiClient.tryCompressChat).toHaveBeenNthCalledWith( + expect(mockLlmClient.tryCompressChat).toHaveBeenCalledTimes(2); + expect(mockLlmClient.tryCompressChat).toHaveBeenNthCalledWith( 2, 'test-session-id########1_stop_hook_1', false, @@ -14658,13 +14657,13 @@ describe('Session', () => { }); expect(scheduler.start).toHaveBeenCalledTimes(1); - expect(mockGeminiClient.tryCompressChat).toHaveBeenNthCalledWith( + expect(mockLlmClient.tryCompressChat).toHaveBeenNthCalledWith( 1, 'test-session-id########1', false, expect.any(AbortSignal), ); - expect(mockGeminiClient.tryCompressChat).toHaveBeenNthCalledWith( + expect(mockLlmClient.tryCompressChat).toHaveBeenNthCalledWith( 2, expect.stringMatching(/^test-session-id########cron\d+$/), false, @@ -14675,7 +14674,7 @@ describe('Session', () => { typeof vi.fn >; expectCompressBeforeSend( - mockGeminiClient.tryCompressChat, + mockLlmClient.tryCompressChat, sendMessageStream, 1, ); @@ -15363,7 +15362,7 @@ describe('Session', () => { it('does not submit delivery when the prompt hits the token limit', async () => { mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(100); - mockGeminiClient.tryCompressChat.mockResolvedValueOnce({ + mockLlmClient.tryCompressChat.mockResolvedValueOnce({ originalTokenCount: 101, newTokenCount: 101, compressionStatus: core.CompressionStatus.NOOP, @@ -17545,7 +17544,7 @@ describe('Session', () => { // Compress on the SECOND cron tick only — keyed on the cron promptId so // the user 'hello' prompt's compression check stays a no-op. let cronCompressions = 0; - mockGeminiClient.tryCompressChat = vi + mockLlmClient.tryCompressChat = vi .fn() .mockImplementation(async (promptId: string) => { const isCron = String(promptId).includes('cron'); @@ -17626,7 +17625,7 @@ describe('Session', () => { mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(100); - mockGeminiClient.tryCompressChat + mockLlmClient.tryCompressChat .mockResolvedValueOnce({ originalTokenCount: 50, newTokenCount: 50, @@ -17647,11 +17646,11 @@ describe('Session', () => { }); await vi.waitFor(() => { - expect(mockGeminiClient.tryCompressChat).toHaveBeenCalledTimes(2); + expect(mockLlmClient.tryCompressChat).toHaveBeenCalledTimes(2); }); expect(scheduler.start).toHaveBeenCalledTimes(1); - expect(mockGeminiClient.tryCompressChat).toHaveBeenNthCalledWith( + expect(mockLlmClient.tryCompressChat).toHaveBeenNthCalledWith( 2, expect.stringMatching(/^test-session-id########cron\d+$/), false, @@ -17710,7 +17709,7 @@ describe('Session', () => { cronCallback?.({ prompt: 'scheduled prompt again' }); await Promise.resolve(); - expect(mockGeminiClient.tryCompressChat).toHaveBeenCalledTimes(2); + expect(mockLlmClient.tryCompressChat).toHaveBeenCalledTimes(2); expect(tokenLimitDiagnosticCount()).toBe(diagnosticCountBefore); }); @@ -17729,7 +17728,7 @@ describe('Session', () => { prompt: [{ type: 'text', text: '/compress' }], }); - expect(mockGeminiClient.tryCompressChat).not.toHaveBeenCalled(); + expect(mockLlmClient.tryCompressChat).not.toHaveBeenCalled(); expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); expect(mockConfig.startActiveTodoWorkChain).not.toHaveBeenCalled(); expect(mockClient.sessionUpdate).toHaveBeenCalledWith({ @@ -19505,7 +19504,7 @@ describe('Session', () => { it('hands off a preempted Goal turn whose stream throws on abort', async () => { // Same user action as the test above, but the preempted stream // rejects out of the model network await instead of ending cleanly -- - // which is what geminiChat actually does. Where the abort lands is + // which is what llmChat actually does. Where the abort lands is // pure timing, so both spellings have to settle the same way: a // handoff via finishTurn, never a pause. Pausing here would persist // the goal as paused and silently stop the autonomous loop. @@ -19581,7 +19580,7 @@ describe('Session', () => { ); } // The one difference from the sibling test: the abort lands - // inside the model network await, so geminiChat rejects instead + // inside the model network await, so llmChat rejects instead // of handing back a stream that ends cleanly. throw Object.assign(new Error('The operation was aborted'), { name: 'AbortError', @@ -22130,9 +22129,9 @@ describe('Session', () => { denialState = next; }); mockConfig.getBaseLlmClient = vi.fn().mockReturnValue(baseLlmClient); - mockConfig.getGeminiClient = vi + mockConfig.getLlmClient = vi .fn() - .mockReturnValue({ ...mockGeminiClient, getHistoryTail }); + .mockReturnValue({ ...mockLlmClient, getHistoryTail }); mockConfig.getAutoModeSettings = vi.fn().mockReturnValue({}); mockConfig.getModel = vi.fn().mockReturnValue('test-model'); mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(true); @@ -22232,9 +22231,9 @@ describe('Session', () => { denialState = next; }); mockConfig.getBaseLlmClient = vi.fn().mockReturnValue(baseLlmClient); - mockConfig.getGeminiClient = vi + mockConfig.getLlmClient = vi .fn() - .mockReturnValue({ ...mockGeminiClient, getHistoryTail }); + .mockReturnValue({ ...mockLlmClient, getHistoryTail }); mockConfig.getAutoModeSettings = vi.fn().mockReturnValue({}); mockConfig.getModel = vi.fn().mockReturnValue('test-model'); mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(true); @@ -22337,9 +22336,9 @@ describe('Session', () => { denialState = next; }); mockConfig.getBaseLlmClient = vi.fn().mockReturnValue(baseLlmClient); - mockConfig.getGeminiClient = vi + mockConfig.getLlmClient = vi .fn() - .mockReturnValue({ ...mockGeminiClient, getHistoryTail }); + .mockReturnValue({ ...mockLlmClient, getHistoryTail }); mockConfig.getAutoModeSettings = vi.fn().mockReturnValue({}); mockConfig.getModel = vi.fn().mockReturnValue('test-model'); mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(true); @@ -22455,9 +22454,9 @@ describe('Session', () => { denialState = next; }); mockConfig.getBaseLlmClient = vi.fn().mockReturnValue(baseLlmClient); - mockConfig.getGeminiClient = vi + mockConfig.getLlmClient = vi .fn() - .mockReturnValue({ ...mockGeminiClient, getHistoryTail }); + .mockReturnValue({ ...mockLlmClient, getHistoryTail }); mockConfig.getAutoModeSettings = vi.fn().mockReturnValue({}); mockConfig.getModel = vi.fn().mockReturnValue('test-model'); mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(true); @@ -22579,7 +22578,7 @@ describe('Session', () => { }); mockConfig.setAutoModeDenialState = setAutoModeDenialState; ( - mockGeminiClient as unknown as { + mockLlmClient as unknown as { getHistoryTail: ReturnType; } ).getHistoryTail = vi.fn().mockReturnValue([]); @@ -30380,7 +30379,7 @@ describe('Session', () => { it('preserves feature-off Stop hook loop reporting before token rejection', async () => { mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(100); mockConfig.getStopHookBlockingCap = vi.fn().mockReturnValue(3); - mockGeminiClient.tryCompressChat.mockResolvedValue({ + mockLlmClient.tryCompressChat.mockResolvedValue({ originalTokenCount: 50, newTokenCount: 50, compressionStatus: core.CompressionStatus.NOOP, @@ -31172,7 +31171,7 @@ describe('Session', () => { }, ); mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(100); - mockGeminiClient.tryCompressChat + mockLlmClient.tryCompressChat .mockResolvedValueOnce({ originalTokenCount: 50, newTokenCount: 50, @@ -31639,7 +31638,7 @@ describe('Session', () => { newTokenCount: 50, compressionStatus: core.CompressionStatus.NOOP, }; - mockGeminiClient.tryCompressChat + mockLlmClient.tryCompressChat .mockResolvedValueOnce(noCompression) .mockResolvedValueOnce(noCompression) .mockImplementationOnce(async () => { @@ -31689,7 +31688,7 @@ describe('Session', () => { newTokenCount: 50, compressionStatus: core.CompressionStatus.NOOP, }; - mockGeminiClient.tryCompressChat.mockImplementation(async () => { + mockLlmClient.tryCompressChat.mockImplementation(async () => { events.push('compression'); return noCompression; }); @@ -31734,7 +31733,7 @@ describe('Session', () => { expect(visionAfterDrainIndex).toBeLessThan(events.indexOf('claim')); expect(events.filter((event) => event === 'compression')).toHaveLength(1); expect(events.indexOf('compression')).toBeLessThan(imageDrainIndex); - expect(mockGeminiClient.tryCompressChat).toHaveBeenCalledOnce(); + expect(mockLlmClient.tryCompressChat).toHaveBeenCalledOnce(); for (const call of vi .mocked(mockChat.sendMessageStream) .mock.calls.slice(1)) { @@ -31759,7 +31758,7 @@ describe('Session', () => { newTokenCount: 50, compressionStatus: core.CompressionStatus.NOOP, }; - mockGeminiClient.tryCompressChat + mockLlmClient.tryCompressChat .mockResolvedValueOnce(noCompression) .mockResolvedValueOnce(noCompression) .mockImplementationOnce(async () => { @@ -31793,7 +31792,7 @@ describe('Session', () => { newTokenCount: 50, compressionStatus: core.CompressionStatus.NOOP, }; - mockGeminiClient.tryCompressChat + mockLlmClient.tryCompressChat .mockResolvedValueOnce(noCompression) .mockResolvedValueOnce(noCompression) .mockResolvedValueOnce({ @@ -31853,7 +31852,7 @@ describe('Session', () => { newTokenCount: 50, compressionStatus: core.CompressionStatus.NOOP, }; - mockGeminiClient.tryCompressChat + mockLlmClient.tryCompressChat .mockResolvedValueOnce(noCompression) .mockResolvedValueOnce(noCompression) .mockRejectedValueOnce(new Error('compression unavailable')) @@ -31930,7 +31929,7 @@ describe('Session', () => { newTokenCount: 50, compressionStatus: core.CompressionStatus.NOOP, }; - mockGeminiClient.tryCompressChat + mockLlmClient.tryCompressChat .mockResolvedValueOnce(noCompression) .mockResolvedValueOnce(noCompression) .mockResolvedValueOnce({ @@ -32143,7 +32142,7 @@ describe('Session', () => { getUserContentPushCount: () => number; } ).getUserContentPushCount = vi.fn(() => userContentPushCount); - const replacementChat = Object.create(mockChat) as GeminiChat; + const replacementChat = Object.create(mockChat) as LlmChat; const replacementAddHistory = vi.fn(); replacementChat.addHistory = replacementAddHistory; replacementChat.getUserContentPushCount = vi.fn( @@ -32197,9 +32196,9 @@ describe('Session', () => { }, ); if (replaceChatAfterCompression) { - mockGeminiClient.tryCompressChat.mockImplementation(async () => { + mockLlmClient.tryCompressChat.mockImplementation(async () => { if (vi.mocked(mockChat.sendMessageStream).mock.calls.length === 3) { - mockGeminiClient.getChat.mockReturnValue(replacementChat); + mockLlmClient.getChat.mockReturnValue(replacementChat); } return { originalTokenCount: 0, @@ -35456,7 +35455,7 @@ describe('Session', () => { installPendingTodoTool(); mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(100); mockConfig.getStopHookBlockingCap = vi.fn().mockReturnValue(3); - mockGeminiClient.tryCompressChat.mockResolvedValue({ + mockLlmClient.tryCompressChat.mockResolvedValue({ originalTokenCount: 50, newTokenCount: 50, compressionStatus: core.CompressionStatus.NOOP, diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index ba1c6ee4ec4..008c86ca3e4 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -17,7 +17,7 @@ import type { } from '@google/genai'; import type { Config, - GeminiChat, + LlmChat, ToolCallConfirmationDetails, ToolConfirmationPayload, ToolResult, @@ -1651,7 +1651,7 @@ export async function registerCreateSubSessionTool( // those configurations. toolRegistry.revealDeferredTool(ToolNames.CREATE_SUB_SESSION); toolRegistry.pinDeferredToolReveal(ToolNames.CREATE_SUB_SESSION); - await config.getGeminiClient().setTools(); + await config.getLlmClient().setTools(); } export interface AvailableCommandsSnapshot { @@ -1848,7 +1848,7 @@ export class Session implements SessionContext { private cronCompletion: Promise | null = null; private cronDisabledByTokenLimit = false; private lastPromptTokenCount = 0; - private lastPromptTokenCountChat: GeminiChat | null = null; + private lastPromptTokenCountChat: LlmChat | null = null; private midTurnDrainUnavailable = false; private midTurnDrainTimeoutStrikes = 0; // ACP can continue one logical conversation through prompt, cron, and @@ -3084,11 +3084,11 @@ export class Session implements SessionContext { } async #syncLiveToolDeclarations(): Promise { - const geminiClient = this.config.getGeminiClient(); - if (!geminiClient) { + const llmClient = this.config.getLlmClient(); + if (!llmClient) { throw new Error('The Live backend model client is unavailable.'); } - await geminiClient.setTools(); + await llmClient.setTools(); } async setLiveConversationActive(active: boolean): Promise { @@ -3106,7 +3106,7 @@ export class Session implements SessionContext { this.liveEndInstructionPending = true; this.config.setLiveAppendSystemPrompt(LIVE_BACKEND_END_INSTRUCTIONS); } - await this.config.getGeminiClient()?.refreshSystemInstruction(); + await this.config.getLlmClient()?.refreshSystemInstruction(); } async appendLiveConversationTranscript( @@ -3155,7 +3155,7 @@ export class Session implements SessionContext { if (this.liveConversationActive || !this.liveEndInstructionPending) return; this.liveEndInstructionPending = false; this.config.setLiveAppendSystemPrompt(undefined); - await this.config.getGeminiClient()?.refreshSystemInstruction(); + await this.config.getLlmClient()?.refreshSystemInstruction(); } getId(): string { @@ -3564,7 +3564,7 @@ export class Session implements SessionContext { ); } - const chat = this.config.getGeminiClient()!.getChat(); + const chat = this.config.getLlmClient()!.getChat(); const apiHistory = chat.getHistoryShallow(); const apiTruncateIndex = this.#computeApiTruncationIndexForUserTurn( apiHistory, @@ -3615,7 +3615,7 @@ export class Session implements SessionContext { } captureHistorySnapshot(): Content[] { - return this.config.getGeminiClient()!.getChat().getHistoryShallow(); + return this.config.getLlmClient()!.getChat().getHistoryShallow(); } getRewindableUserTurnCount(): number { @@ -3642,10 +3642,7 @@ export class Session implements SessionContext { ); } - this.config - .getGeminiClient()! - .getChat() - .setHistory(structuredClone(history)); + this.config.getLlmClient()!.getChat().setHistory(structuredClone(history)); this.activeTodoPlanRevision = undefined; this.#clearTodoStopGuardTrustAndDrainAutomaticQueues(); } @@ -4235,8 +4232,8 @@ export class Session implements SessionContext { accepted: boolean; interruption: 'none' | 'interrupted_prompt' | 'interrupted_turn'; }> { - const geminiClient = this.config.getGeminiClient(); - if (!geminiClient || !geminiClient.isInitialized()) { + const llmClient = this.config.getLlmClient(); + if (!llmClient || !llmClient.isInitialized()) { return { accepted: false, interruption: 'none' }; } @@ -4319,7 +4316,7 @@ export class Session implements SessionContext { if (this.settings.merged.ui?.enableFollowupSuggestions === false) return; if (this.config.getApprovalMode() === ApprovalMode.PLAN) return; - const chat = this.config.getGeminiClient()?.getChat(); + const chat = this.config.getLlmClient()?.getChat(); if (!chat) return; const ac = new AbortController(); @@ -4465,7 +4462,7 @@ export class Session implements SessionContext { // it, `qwen review fetch-pr` cannot record its worktree lease and an // interrupted /review leaves the review worktree behind. TUI and // headless enter this context at their prompt entry points - // (useGeminiStream.ts / nonInteractiveCli.ts); ACP had no equivalent. + // (use-llm-stream.ts / nonInteractiveCli.ts); ACP had no equivalent. // enterWith (not run) so the 500-line turn body below stays unnested; // the binding dies with this async scope. promptIdContext.enterWith(promptId); @@ -4859,7 +4856,7 @@ export class Session implements SessionContext { this.activeTodoWorkChainPromptId = promptId; // Snapshot file state before this turn (mirrors the makeSnapshot - // block in GeminiClient.sendMessageStream). Placed after + // block in LlmClient.sendMessageStream). Placed after // slash-command and hook early-returns so locally handled commands // don't create phantom snapshots that desync the snapshot index. // Restore continuations record no user message; rewindToTurn() @@ -4887,7 +4884,7 @@ export class Session implements SessionContext { // Prepend session-level system reminders (plan mode / subagent / // arena) so the model sees them, matching the behaviour of - // `GeminiClient.sendMessageStream` in the CLI/TUI path. Without this, + // `LlmClient.sendMessageStream` in the CLI/TUI path. Without this, // plan mode in ACP has no effect because the model never learns it // should avoid edits. const systemReminders = await this.#buildInitialSystemReminders(); @@ -4977,7 +4974,7 @@ export class Session implements SessionContext { // not just the stop-hook loop. Daemon turns run autonomously in // all approval modes (approvals are mediated by the ACP client // rather than by gating this loop), so unlike the CLI reference - // (useGeminiStream.ts, which only emits in YOLO) this is + // (use-llm-stream.ts, which only emits in YOLO) this is // intentionally emitted for every mode. try { if (isRestoreAskUserQuestion) { @@ -5299,7 +5296,7 @@ export class Session implements SessionContext { this.todoStopGuard.pauseForTrustedRetry(); // Fire StopFailure hook (fire-and-forget, replaces Stop event for API errors) - // Aligned with useGeminiStream.ts handleFinishedWithErrorEvent + // Aligned with use-llm-stream.ts handleFinishedWithErrorEvent const errorStatus = getErrorStatus(error); const errorMessage = error instanceof Error ? error.message : String(error); @@ -5887,7 +5884,7 @@ export class Session implements SessionContext { | ChannelDeliveryResponseBlock | undefined; let channelDeliveryCheckpoint = 0; - let providerSendChat: GeminiChat | undefined; + let providerSendChat: LlmChat | undefined; let userContentPushCountBeforeSend = 0; try { @@ -6648,8 +6645,8 @@ export class Session implements SessionContext { } } - #getCurrentChat(): GeminiChat { - return this.config.getGeminiClient()!.getChat(); + #getCurrentChat(): LlmChat { + return this.config.getLlmClient()!.getChat(); } async #runWithFullTurnModel( @@ -6668,9 +6665,9 @@ export class Session implements SessionContext { /** * Create the MessageDisplay hook dispatcher for one model call's streamed * reply, or null when the hook isn't registered (the common case — keeps - * the streaming loops zero-cost). The ACP surface consumes GeminiChat's + * the streaming loops zero-cost). The ACP surface consumes LlmChat's * raw stream directly rather than going through - * GeminiClient.sendMessageStream, so it has to fire this hook itself — + * LlmClient.sendMessageStream, so it has to fire this hook itself — * with the same contract as the terminal UI path in client.ts: debounced * cumulative text, one message_id per model call, and an is_final firing * on every non-aborted exit (delivered by awaiting `finish()` in a @@ -6716,7 +6713,7 @@ export class Session implements SessionContext { ) => Promise; } = {}, ): Promise { - const geminiClient = this.config.getGeminiClient()!; + const llmClient = this.config.getLlmClient()!; if (options.prepareBeforeCompression) { const decision = await options.prepareBeforeCompression(); if (decision.kind === 'stop') { @@ -6740,7 +6737,7 @@ export class Session implements SessionContext { !(options.getModelOverride?.() ?? options.modelOverride) ) { try { - const compressed = await geminiClient.tryCompressChat( + const compressed = await llmClient.tryCompressChat( promptId, false, abortSignal, @@ -9856,13 +9853,13 @@ export class Session implements SessionContext { /** * Assemble the per-turn system reminders the model needs to see at the * start of a user query or cron fire. Mirrors the subagent/plan/arena - * branches in `GeminiClient.sendMessageStream` (`client.ts:848-878`) — + * branches in `LlmClient.sendMessageStream` (`client.ts:848-878`) — * the ACP path bypasses that code, so without this helper plan mode is * silently inert and subagent/arena sessions lose context. * * Scope note: the `relevantAutoMemory` reminder is intentionally NOT * included here. Managed auto-memory requires a prefetch pipeline that - * lives in `GeminiClient`, and porting it into the ACP path is tracked + * lives in `LlmClient`, and porting it into the ACP path is tracked * separately as part of the broader middleware-alignment work. */ async #buildInitialSystemReminders(): Promise { @@ -10488,7 +10485,7 @@ export class Session implements SessionContext { // Parallels coreToolScheduler.ts. const messages = this.config - .getGeminiClient?.() + .getLlmClient?.() ?.getHistoryTail(MAX_TRANSCRIPT_MESSAGES, false) ?? []; const decision = await evaluateAutoMode({ ctx: pmCtx, diff --git a/packages/cli/src/acp-integration/session/Session.worktree.test.ts b/packages/cli/src/acp-integration/session/Session.worktree.test.ts index a14805aced0..b8a57a321f6 100644 --- a/packages/cli/src/acp-integration/session/Session.worktree.test.ts +++ b/packages/cli/src/acp-integration/session/Session.worktree.test.ts @@ -18,7 +18,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { Session } from './Session.js'; -import type { Config, GeminiChat } from '@qwen-code/qwen-code-core'; +import type { Config, LlmChat } from '@qwen-code/qwen-code-core'; import { ApprovalMode, AuthType, @@ -66,7 +66,7 @@ describe('Session.pendingWorktreeNotice', () => { /** Parts arrays captured on each sendMessageStream call. */ let capturedMessages: unknown[][]; - let mockChat: GeminiChat; + let mockChat: LlmChat; let mockConfig: Config; let mockClient: AgentSideConnection; let mockSettings: LoadedSettings; @@ -93,9 +93,9 @@ describe('Session.pendingWorktreeNotice', () => { setHistory: vi.fn(), truncateHistory: vi.fn(), stripThoughtsFromHistory: vi.fn(), - } as unknown as GeminiChat; + } as unknown as LlmChat; - const mockGeminiClient = { + const mockLlmClient = { getChat: vi.fn().mockReturnValue(mockChat), tryCompressChat: vi.fn().mockResolvedValue({ originalTokenCount: 0, @@ -159,7 +159,7 @@ describe('Session.pendingWorktreeNotice', () => { getAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI), isCronEnabled: vi.fn().mockReturnValue(false), getSessionTokenLimit: vi.fn().mockReturnValue(0), - getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient), + getLlmClient: vi.fn().mockReturnValue(mockLlmClient), getDisableAllHooks: vi.fn().mockReturnValue(true), hasHooksForEvent: vi.fn().mockReturnValue(false), getMessageBus: vi.fn().mockReturnValue(undefined), diff --git a/packages/cli/src/acp-integration/session/history-replay-page.test.ts b/packages/cli/src/acp-integration/session/history-replay-page.test.ts index ebe35b9e9bd..12a677fb7fe 100644 --- a/packages/cli/src/acp-integration/session/history-replay-page.test.ts +++ b/packages/cli/src/acp-integration/session/history-replay-page.test.ts @@ -211,7 +211,7 @@ describe('history replay page', () => { // getChat() THROWS there. The skip probe must guard on isInitialized(). const config = { getRestoreAskUserQuestion: () => true, - getGeminiClient: () => ({ + getLlmClient: () => ({ isInitialized: () => false, getChat: () => { throw new Error('Chat not initialized'); @@ -262,7 +262,7 @@ describe('history replay page', () => { }; const config = { getRestoreAskUserQuestion: () => true, - getGeminiClient: () => ({ + getLlmClient: () => ({ isInitialized: () => true, getChat: () => ({ peekLastHistoryEntry: () => lastEntry }), }), diff --git a/packages/cli/src/acp-integration/session/history-replay-page.ts b/packages/cli/src/acp-integration/session/history-replay-page.ts index 917da35d170..23d0ed85c4d 100644 --- a/packages/cli/src/acp-integration/session/history-replay-page.ts +++ b/packages/cli/src/acp-integration/session/history-replay-page.ts @@ -283,7 +283,7 @@ export async function collectHistoryReplayUpdates({ suppressRestoreAskUserQuestion !== true && config?.getRestoreAskUserQuestion?.() === true ) { - const replayClient = config.getGeminiClient?.(); + const replayClient = config.getLlmClient?.(); const lastHistoryContent = replayClient?.isInitialized?.() === true ? (replayClient.getChat?.()?.peekLastHistoryEntry?.() ?? diff --git a/packages/cli/src/acp-integration/session/history-replayer.test.ts b/packages/cli/src/acp-integration/session/history-replayer.test.ts index ee0f9a212e5..134239ef043 100644 --- a/packages/cli/src/acp-integration/session/history-replayer.test.ts +++ b/packages/cli/src/acp-integration/session/history-replayer.test.ts @@ -1578,7 +1578,7 @@ describe('collectHistoryReplayUpdates restore skip', () => { it('skips finalize from the transcript tail when chat is not initialized', async () => { const config = { getRestoreAskUserQuestion: () => true, - getGeminiClient: () => ({ isInitialized: () => false }), + getLlmClient: () => ({ isInitialized: () => false }), } as unknown as Config; const replay = await collectHistoryReplayUpdates({ @@ -1596,7 +1596,7 @@ describe('collectHistoryReplayUpdates restore skip', () => { it('finalizes when restore skip is suppressed', async () => { const config = { getRestoreAskUserQuestion: () => true, - getGeminiClient: () => ({ isInitialized: () => false }), + getLlmClient: () => ({ isInitialized: () => false }), } as unknown as Config; const replay = await collectHistoryReplayUpdates({ diff --git a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.test.ts b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.test.ts index 604293f9d51..28ba1f32c46 100644 --- a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.test.ts +++ b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.test.ts @@ -42,7 +42,7 @@ const { LlmRewriter } = await import('./LlmRewriter.js'); function makeConfig(): Config { return { getModel: () => 'test-model', - getGeminiClient: () => ({}), + getLlmClient: () => ({}), } as unknown as Config; } diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 44bf2b2ba3b..095a1cbf035 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -51,7 +51,7 @@ const mocks = vi.hoisted(() => ({ installManagedNpmUpdate: vi.fn(), })); -vi.mock('./gemini.js', () => ({ +vi.mock('./llm.js', () => ({ main: mocks.main, })); @@ -513,8 +513,8 @@ describe('bootstrap import boundaries', () => { expect(source).not.toContain("import yargs from 'yargs'"); expect(source).not.toContain("from '@qwen-code/qwen-code-core'"); - expect(source).not.toContain("import './gemini.js'"); - expect(source).not.toContain("import { main } from './gemini.js'"); + expect(source).not.toContain("import './llm.js'"); + expect(source).not.toContain("import { main } from './llm.js'"); expect(source).not.toContain("from './utils/acp-startup-profiler.js'"); }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7e2929bce21..37434e05407 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -398,7 +398,7 @@ export async function runCliEntry( : undefined; acpStartupProfiler?.initializeAcpStartupProfiler(); acpStartupProfiler?.markAcpStartup('geminiImportStart'); - const { main } = await import('./gemini.js'); + const { main } = await import('./llm.js'); acpStartupProfiler?.markAcpStartup('geminiImportEnd'); await main(); } @@ -512,7 +512,7 @@ export function stampCliEntryEnv(entryPath?: string): void { // handleUncaughtException and isExpectedPtyRaceError live in // ./utils/uncaught-exception-handler.js and are re-exported here for existing -// importers (cli.test.ts). gemini.tsx must import them from that leaf module +// importers (cli.test.ts). llm.tsx must import them from that leaf module // directly: a static import of this entry file from a module the bundle loads // lazily makes esbuild hoist this entry into a shared chunk, which silently // disables the bootstrap guard at the bottom. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 60ba66b9c81..502de9570f7 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -46,7 +46,7 @@ import { HEADLESS_YOLO_NO_SANDBOX_WARNING } from '../utils/headlessSafetyWarning * Pause the current async function indefinitely. Used after the daemon * listener is up so yargs `parse()` never resolves — if it did, the * top-level CLI would fall through to the interactive (TUI) entry point - * in `gemini.tsx`. SIGINT / SIGTERM in `runQwenServe` is the sole exit + * in `llm.tsx`. SIGINT / SIGTERM in `runQwenServe` is the sole exit * route. */ function blockForever(): Promise { diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 00e824ad388..427d5b986b7 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -521,7 +521,7 @@ describe('parseArguments', () => { it('rejects --json-schema combined with --input-format stream-json', async () => { // The "first valid structured_output call ends the session" // contract is incompatible with the long-lived stream-json input - // protocol. Also load-bearing: gemini.tsx's + // protocol. Also load-bearing: llm.tsx's // `process.exit(process.exitCode ?? 0)` plumbing in the stream-json // branch explicitly relies on this rejection holding. Pair with // --output-format stream-json because input/output formats must @@ -1169,10 +1169,7 @@ describe('loadCliConfig', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(); const settings: Settings = {}; - const setMemoryFilenameSpy = vi.spyOn( - ServerConfig, - 'setMemoryFilename', - ); + const setMemoryFilenameSpy = vi.spyOn(ServerConfig, 'setMemoryFilename'); await loadCliConfig(settings, argv); @@ -1270,10 +1267,7 @@ describe('loadCliConfig', () => { fileName: 'CUSTOM_AGENTS.md', }, }; - const setMemoryFilenameSpy = vi.spyOn( - ServerConfig, - 'setMemoryFilename', - ); + const setMemoryFilenameSpy = vi.spyOn(ServerConfig, 'setMemoryFilename'); await loadCliConfig(settings, argv); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 3f5f5badb75..866f2744c4c 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1535,7 +1535,7 @@ export async function loadCliConfig( */ sessionMcpServers?: Record, /** - * Lifecycle handle for the settings file watcher started in `gemini.tsx` + * Lifecycle handle for the settings file watcher started in `llm.tsx` * before `Config.initialize()`. Passed through to `Config` so it can be * stopped during shutdown — only `stopWatching()` is exposed here to keep * core decoupled from the CLI-owned `SettingsWatcher` implementation. @@ -1999,7 +1999,7 @@ export async function loadCliConfig( if (argv.resume) { // By the time we get here, argv.resume has been resolved to a valid - // session UUID by gemini.tsx (which handles custom title lookup and + // session UUID by llm.tsx (which handles custom title lookup and // the interactive picker for ambiguous matches). sessionId = argv.resume; deferProjectionUntilWriterLease = diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index e8b54c013f2..9f8a1136d4d 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -2222,7 +2222,7 @@ describe('Settings Loading and Merging', () => { const warnings = getSettingsWarnings(result); // Corruption warning no longer goes through migrationWarnings — - // it is emitted via settings.corruptedPath check in gemini.tsx + // it is emitted via settings.corruptedPath check in llm.tsx // early stderr path instead. Verify corruptedPath is set. expect(result.corruptedPath).toBeDefined(); expect(warnings.some((w) => w.includes('invalid JSON'))).toBe(false); diff --git a/packages/cli/src/dualOutput/DualOutputBridge.ts b/packages/cli/src/dualOutput/DualOutputBridge.ts index e3f34521611..1e1a4e71faf 100644 --- a/packages/cli/src/dualOutput/DualOutputBridge.ts +++ b/packages/cli/src/dualOutput/DualOutputBridge.ts @@ -13,7 +13,7 @@ import { } from 'node:fs'; import type { Config, - ServerGeminiStreamEvent, + ServerLlmStreamEvent, ToolCallRequestInfo, ToolCallResponseInfo, } from '@qwen-code/qwen-code-core'; @@ -205,7 +205,7 @@ export class DualOutputBridge { } } - processEvent(event: ServerGeminiStreamEvent): void { + processEvent(event: ServerLlmStreamEvent): void { if (!this.active) return; this.disableIfBufferOverflowed(); if (!this.active) return; diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/llm.test.tsx similarity index 99% rename from packages/cli/src/gemini.test.tsx rename to packages/cli/src/llm.test.tsx index 7c68c619187..e1e075af419 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/llm.test.tsx @@ -28,7 +28,7 @@ import { registerLspHotReload, setupUnhandledRejectionHandler, validateDnsResolutionOrder, -} from './gemini.js'; +} from './llm.js'; import { startInteractiveUI } from './ui/startInteractiveUI.js'; import { clearCiEnv } from './test-utils/ci-env.js'; import type { CliArgs } from './config/config.js'; @@ -74,7 +74,7 @@ const sessionRegistryConfigStub = { describe('gemini import boundary', () => { it('does not statically import ACP or noninteractive auth branches', () => { - const source = readFileSync('src/gemini.tsx', 'utf8'); + const source = readFileSync('src/llm.tsx', 'utf8'); expect(source).not.toContain( "import { runAcpAgent } from './acp-integration/acpAgent.js'", @@ -292,7 +292,7 @@ function withLspDisabledConfig( }; } -describe('gemini.tsx main function', () => { +describe('llm.tsx main function', () => { let originalEnvGeminiSandbox: string | undefined; let originalEnvSandbox: string | undefined; let originalEnvQwenSandboxImage: string | undefined; @@ -1633,7 +1633,7 @@ describe('gemini.tsx main function', () => { }); }); -describe('gemini.tsx main function kitty protocol', () => { +describe('llm.tsx main function kitty protocol', () => { let originalEnvNoRelaunch: string | undefined; let setRawModeSpy: MockInstance< (mode: boolean) => NodeJS.ReadStream & { fd: 0 } @@ -2514,7 +2514,7 @@ describe('gemini.tsx main function kitty protocol', () => { // The synthetic structured_output tool only terminates the run inside // runNonInteractive. In TUI mode it's an inert tool that prints // "accepted" and leaves the chat alive — silently stranding the run. - // gemini.tsx must reject this combination at runtime (parse-time + // llm.tsx must reject this combination at runtime (parse-time // gating can't catch the no-prompt-on-TTY case because stdin // availability isn't probed yet at parse time). const { loadCliConfig, parseArguments } = await import( diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/llm.tsx similarity index 99% rename from packages/cli/src/gemini.tsx rename to packages/cli/src/llm.tsx index d0204370920..5b62beb5af5 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/llm.tsx @@ -345,7 +345,7 @@ export async function main() { profileCheckpoint('main_entry'); const acpStartupProfilerEnabled = isAcpStartupProfilerEnabled(); // Bridge core-package startup events (Config.initialize, MCP discovery, - // GeminiClient.setTools) into the cli's startup profiler. Gated on + // LlmClient.setTools) into the cli's startup profiler. Gated on // `isStartupProfilerEnabled()` so that when QWEN_CODE_PROFILE_STARTUP is // unset (the common case) every core-side `recordStartupEvent()` call // sees a null sink and short-circuits at the first comparison, instead diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts index 11f213a0684..6c355e135e9 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts @@ -7,9 +7,9 @@ import { Buffer } from 'node:buffer'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { - GeminiEventType, + LlmEventType, type Config, - type ServerGeminiStreamEvent, + type ServerLlmStreamEvent, type ToolCallRequestInfo, type AgentResultDisplay, } from '@qwen-code/qwen-code-core'; @@ -293,7 +293,7 @@ describe('BaseJsonOutputAdapter', () => { it('should build message with text blocks', () => { adapter.startAssistantMessage(); adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Hello world', }); @@ -316,7 +316,7 @@ describe('BaseJsonOutputAdapter', () => { it('should set stop_reason to tool_use when message contains only tool_use blocks', () => { adapter.startAssistantMessage(); adapter.processEvent({ - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-1', name: 'test_tool', @@ -443,7 +443,7 @@ describe('BaseJsonOutputAdapter', () => { adapter.startAssistantMessage(); const state = adapter['mainAgentMessageState']; adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'text', }); @@ -483,7 +483,7 @@ describe('BaseJsonOutputAdapter', () => { adapter.startAssistantMessage(); const state = adapter['mainAgentMessageState']; adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'test', }); @@ -504,7 +504,7 @@ describe('BaseJsonOutputAdapter', () => { adapter.startAssistantMessage(); const state = adapter['mainAgentMessageState']; adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'test', }); @@ -522,7 +522,7 @@ describe('BaseJsonOutputAdapter', () => { adapter.startAssistantMessage(); const state = adapter['mainAgentMessageState']; adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'test', }); state.openBlocks.add(0); @@ -768,7 +768,7 @@ describe('BaseJsonOutputAdapter', () => { it('should reset main agent message state', () => { adapter.startAssistantMessage(); adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'test', }); @@ -787,7 +787,7 @@ describe('BaseJsonOutputAdapter', () => { it('should process Content events', () => { adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Hello', }); @@ -801,7 +801,7 @@ describe('BaseJsonOutputAdapter', () => { it('should process Citation events', () => { adapter.processEvent({ - type: GeminiEventType.Citation, + type: LlmEventType.Citation, value: 'Citation text', }); @@ -813,9 +813,9 @@ describe('BaseJsonOutputAdapter', () => { it('should ignore non-string Citation values', () => { adapter.processEvent({ - type: GeminiEventType.Citation, + type: LlmEventType.Citation, value: 123, - } as unknown as ServerGeminiStreamEvent); + } as unknown as ServerLlmStreamEvent); const state = adapter['mainAgentMessageState']; expect(state.blocks).toHaveLength(0); @@ -823,7 +823,7 @@ describe('BaseJsonOutputAdapter', () => { it('should process Thought events', () => { adapter.processEvent({ - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: { subject: 'Planning', description: 'Thinking', @@ -841,7 +841,7 @@ describe('BaseJsonOutputAdapter', () => { it('should process ToolCallRequest events', () => { adapter.processEvent({ - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-1', name: 'test_tool', @@ -863,7 +863,7 @@ describe('BaseJsonOutputAdapter', () => { it('should process Finished events with usage metadata', () => { adapter.processEvent({ - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { @@ -882,13 +882,13 @@ describe('BaseJsonOutputAdapter', () => { it('should ignore events after finalization', () => { adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'First', }); adapter.finalizeAssistantMessage(); adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Second', }); @@ -907,7 +907,7 @@ describe('BaseJsonOutputAdapter', () => { it('should build and return assistant message', () => { adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Test response', }); @@ -1273,7 +1273,7 @@ describe('BaseJsonOutputAdapter', () => { beforeEach(() => { adapter.startAssistantMessage(); adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Response text', }); const message = adapter.finalizeAssistantMessage(); diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts index cf874d1b0cb..fbb4321b2e8 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts @@ -10,14 +10,14 @@ import type { ToolCallRequestInfo, ToolCallResponseInfo, SessionMetrics, - ServerGeminiStreamEvent, + ServerLlmStreamEvent, AgentResultDisplay, McpToolProgressData, ShellProgressData, } from '@qwen-code/qwen-code-core'; import { formatVisionBridgeNoticeDisplay, - GeminiEventType, + LlmEventType, isVisionBridgeNoticeDisplay, ToolErrorType, parseAndFormatApiError, @@ -118,7 +118,7 @@ export interface MessageEmitter { */ export interface JsonOutputAdapterInterface extends MessageEmitter { startAssistantMessage(): void; - processEvent(event: ServerGeminiStreamEvent): void; + processEvent(event: ServerLlmStreamEvent): void; finalizeAssistantMessage(): CLIAssistantMessage; emitResult(options: ResultOptions): void; @@ -201,7 +201,7 @@ export abstract class BaseJsonOutputAdapter { /** * Creates a Usage object from metadata. * - * @param metadata - Optional usage metadata from Gemini API + * @param metadata - Optional LLM usage metadata * @returns Usage object */ protected createUsage( @@ -605,27 +605,27 @@ export abstract class BaseJsonOutputAdapter { } /** - * Processes a stream event from the Gemini API. + * Processes an LLM stream event. * This is a shared implementation used by both streaming and non-streaming adapters. * - * @param event - Stream event from Gemini API + * @param event - LLM stream event */ - processEvent(event: ServerGeminiStreamEvent): void { + processEvent(event: ServerLlmStreamEvent): void { const state = this.mainAgentMessageState; if (state.finalized) { return; } switch (event.type) { - case GeminiEventType.Content: + case LlmEventType.Content: this.appendText(state, event.value, null); break; - case GeminiEventType.Citation: + case LlmEventType.Citation: if (typeof event.value === 'string') { this.appendText(state, `\n${event.value}`, null); } break; - case GeminiEventType.Thought: + case LlmEventType.Thought: this.appendThinking( state, event.value.subject, @@ -633,16 +633,16 @@ export abstract class BaseJsonOutputAdapter { null, ); break; - case GeminiEventType.ToolCallRequest: + case LlmEventType.ToolCallRequest: this.appendToolUse(state, event.value, null); break; - case GeminiEventType.Finished: + case LlmEventType.Finished: if (event.value?.usageMetadata) { state.usage = this.createUsage(event.value.usageMetadata); } this.finalizePendingBlocks(state, null); break; - case GeminiEventType.Error: { + case LlmEventType.Error: { // Format the error message using parseAndFormatApiError for consistency // with interactive mode error display const errorText = parseAndFormatApiError( @@ -652,7 +652,7 @@ export abstract class BaseJsonOutputAdapter { this.appendText(state, errorText, null); break; } - case GeminiEventType.ModelFallback: + case LlmEventType.ModelFallback: // Surface model fallback transitions so non-interactive consumers // (CI pipelines, SDK clients) can observe capacity-driven model // switches without parsing assistant content. diff --git a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts index b47fb1386b9..5e527b30551 100644 --- a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts +++ b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts @@ -6,11 +6,8 @@ import { Buffer } from 'node:buffer'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import type { - Config, - ServerGeminiStreamEvent, -} from '@qwen-code/qwen-code-core'; -import { GeminiEventType, OutputFormat } from '@qwen-code/qwen-code-core'; +import type { Config, ServerLlmStreamEvent } from '@qwen-code/qwen-code-core'; +import { LlmEventType, OutputFormat } from '@qwen-code/qwen-code-core'; import type { Part } from '@google/genai'; import { JsonOutputAdapter } from './JsonOutputAdapter.js'; import { @@ -59,14 +56,14 @@ describe('JsonOutputAdapter', () => { }); it('should append text content from Content events', () => { - const event: ServerGeminiStreamEvent = { - type: GeminiEventType.Content, + const event: ServerLlmStreamEvent = { + type: LlmEventType.Content, value: 'Hello', }; adapter.processEvent(event); - const event2: ServerGeminiStreamEvent = { - type: GeminiEventType.Content, + const event2: ServerLlmStreamEvent = { + type: LlmEventType.Content, value: ' World', }; adapter.processEvent(event2); @@ -80,8 +77,8 @@ describe('JsonOutputAdapter', () => { }); it('should append citation content from Citation events', () => { - const event: ServerGeminiStreamEvent = { - type: GeminiEventType.Citation, + const event: ServerLlmStreamEvent = { + type: LlmEventType.Citation, value: 'Citation text', }; adapter.processEvent(event); @@ -94,10 +91,10 @@ describe('JsonOutputAdapter', () => { }); it('should ignore non-string citation values', () => { - const event: ServerGeminiStreamEvent = { - type: GeminiEventType.Citation, + const event: ServerLlmStreamEvent = { + type: LlmEventType.Citation, value: 123, - } as unknown as ServerGeminiStreamEvent; + } as unknown as ServerLlmStreamEvent; adapter.processEvent(event); const message = adapter.finalizeAssistantMessage(); @@ -105,8 +102,8 @@ describe('JsonOutputAdapter', () => { }); it('should append thinking from Thought events', () => { - const event: ServerGeminiStreamEvent = { - type: GeminiEventType.Thought, + const event: ServerLlmStreamEvent = { + type: LlmEventType.Thought, value: { subject: 'Planning', description: 'Thinking about the task', @@ -124,8 +121,8 @@ describe('JsonOutputAdapter', () => { }); it('should handle thinking with only subject', () => { - const event: ServerGeminiStreamEvent = { - type: GeminiEventType.Thought, + const event: ServerLlmStreamEvent = { + type: LlmEventType.Thought, value: { subject: 'Planning', description: '', @@ -141,8 +138,8 @@ describe('JsonOutputAdapter', () => { }); it('should append tool use from ToolCallRequest events', () => { - const event: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const event: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-call-1', name: 'test_tool', @@ -165,7 +162,7 @@ describe('JsonOutputAdapter', () => { it('should set stop_reason to tool_use when message contains only tool_use blocks', () => { adapter.processEvent({ - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-call-1', name: 'test_tool', @@ -181,7 +178,7 @@ describe('JsonOutputAdapter', () => { it('should set stop_reason to null when message contains text blocks', () => { adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Some text', }); @@ -191,7 +188,7 @@ describe('JsonOutputAdapter', () => { it('should set stop_reason to null when message contains thinking blocks', () => { adapter.processEvent({ - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: { subject: 'Planning', description: 'Thinking about the task', @@ -204,7 +201,7 @@ describe('JsonOutputAdapter', () => { it('should set stop_reason to tool_use when message contains multiple tool_use blocks', () => { adapter.processEvent({ - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-call-1', name: 'test_tool_1', @@ -214,7 +211,7 @@ describe('JsonOutputAdapter', () => { }, }); adapter.processEvent({ - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-call-2', name: 'test_tool_2', @@ -239,8 +236,8 @@ describe('JsonOutputAdapter', () => { cachedContentTokenCount: 10, totalTokenCount: 160, }; - const event: ServerGeminiStreamEvent = { - type: GeminiEventType.Finished, + const event: ServerLlmStreamEvent = { + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata, @@ -260,12 +257,12 @@ describe('JsonOutputAdapter', () => { it('should finalize pending blocks on Finished event', () => { // Add some text first adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Some text', }); - const event: ServerGeminiStreamEvent = { - type: GeminiEventType.Finished, + const event: ServerLlmStreamEvent = { + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: undefined }, }; adapter.processEvent(event); @@ -280,7 +277,7 @@ describe('JsonOutputAdapter', () => { adapter.finalizeAssistantMessage().message.content; adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Should be ignored', }); @@ -296,7 +293,7 @@ describe('JsonOutputAdapter', () => { it('should build and emit a complete assistant message', () => { adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Test response', }); @@ -313,7 +310,7 @@ describe('JsonOutputAdapter', () => { it('should return same message on subsequent calls', () => { adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Test', }); @@ -325,11 +322,11 @@ describe('JsonOutputAdapter', () => { it('should split different block types into separate assistant messages', () => { adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Text', }); adapter.processEvent({ - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: { subject: 'Thinking', description: 'Thought' }, }); @@ -386,7 +383,7 @@ describe('JsonOutputAdapter', () => { beforeEach(() => { adapter.startAssistantMessage(); adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Response text', }); adapter.finalizeAssistantMessage(); @@ -843,7 +840,7 @@ describe('JsonOutputAdapter', () => { }, ); adapter.startAssistantMessage(); - adapter.processEvent({ type: GeminiEventType.Content, value: 'done' }); + adapter.processEvent({ type: LlmEventType.Content, value: 'done' }); adapter.finalizeAssistantMessage(); const storedMessages = ( @@ -918,7 +915,7 @@ describe('JsonOutputAdapter', () => { adapter.emitUserMessage([{ text: 'User input' }]); adapter.startAssistantMessage(); adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Assistant response', }); adapter.finalizeAssistantMessage(); diff --git a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.dualOutput.test.ts b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.dualOutput.test.ts index 208bc1229a8..6e3e90cd999 100644 --- a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.dualOutput.test.ts +++ b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.dualOutput.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import type { Config } from '@qwen-code/qwen-code-core'; -import { GeminiEventType } from '@qwen-code/qwen-code-core'; +import { LlmEventType } from '@qwen-code/qwen-code-core'; import { StreamJsonOutputAdapter } from './StreamJsonOutputAdapter.js'; /** @@ -56,7 +56,7 @@ describe('StreamJsonOutputAdapter — dual-output extensions', () => { ); adapter.startAssistantMessage(); adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'sidecar', }); adapter.finalizeAssistantMessage(); diff --git a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts index f2ebc70d41b..2ab336cd8d7 100644 --- a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts +++ b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts @@ -9,9 +9,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import type { Config, GoalSnapshotV2, - ServerGeminiStreamEvent, + ServerLlmStreamEvent, } from '@qwen-code/qwen-code-core'; -import { GeminiEventType } from '@qwen-code/qwen-code-core'; +import { LlmEventType } from '@qwen-code/qwen-code-core'; import type { Part } from '@google/genai'; import { StreamJsonOutputAdapter } from './StreamJsonOutputAdapter.js'; import { @@ -70,14 +70,14 @@ describe('StreamJsonOutputAdapter', () => { it('should reset state for new message', () => { adapter.startAssistantMessage(); adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'First', }); adapter.finalizeAssistantMessage(); adapter.startAssistantMessage(); adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Second', }); @@ -96,7 +96,7 @@ describe('StreamJsonOutputAdapter', () => { it('should emit stream events for text deltas', () => { adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Hello', }); @@ -126,7 +126,7 @@ describe('StreamJsonOutputAdapter', () => { it('should emit active goal stream events', () => { adapter.processEvent({ - type: GeminiEventType.ActiveGoal, + type: LlmEventType.ActiveGoal, value: { condition: 'finish the refactor', iterations: 2, @@ -138,7 +138,7 @@ describe('StreamJsonOutputAdapter', () => { }); adapter.processEvent({ - type: GeminiEventType.ActiveGoal, + type: LlmEventType.ActiveGoal, value: null, }); @@ -179,12 +179,12 @@ describe('StreamJsonOutputAdapter', () => { it('emits v2 goal_state before the gated legacy projection', () => { adapter.processEvent({ - type: GeminiEventType.GoalState, + type: LlmEventType.GoalState, value: goalSnapshot, cause: 'edit', }); adapter.processEvent({ - type: GeminiEventType.ActiveGoal, + type: LlmEventType.ActiveGoal, value: { condition: 'finish the refactor', iterations: 3, @@ -221,11 +221,11 @@ describe('StreamJsonOutputAdapter', () => { it('does not emit duplicate v2 goal snapshots from overlapping sources', () => { adapter.processEvent({ - type: GeminiEventType.GoalState, + type: LlmEventType.GoalState, value: goalSnapshot, }); adapter.processEvent({ - type: GeminiEventType.GoalState, + type: LlmEventType.GoalState, value: structuredClone(goalSnapshot), }); @@ -240,7 +240,7 @@ describe('StreamJsonOutputAdapter', () => { it('should emit message_start event on first content', () => { adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'First', }); @@ -262,7 +262,7 @@ describe('StreamJsonOutputAdapter', () => { it('should emit content_block_start for new blocks', () => { adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Text', }); @@ -284,7 +284,7 @@ describe('StreamJsonOutputAdapter', () => { it('should emit thinking delta events', () => { adapter.processEvent({ - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: { subject: 'Planning', description: 'Thinking', @@ -310,7 +310,7 @@ describe('StreamJsonOutputAdapter', () => { it('should emit message_stop on finalization', () => { adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Text', }); adapter.finalizeAssistantMessage(); @@ -341,7 +341,7 @@ describe('StreamJsonOutputAdapter', () => { it('should not emit stream events', () => { adapter.startAssistantMessage(); adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Text', }); @@ -360,7 +360,7 @@ describe('StreamJsonOutputAdapter', () => { it('should not emit active goal stream events', () => { adapter.processEvent({ - type: GeminiEventType.ActiveGoal, + type: LlmEventType.ActiveGoal, value: { condition: 'finish the refactor', iterations: 0, @@ -389,12 +389,12 @@ describe('StreamJsonOutputAdapter', () => { it('still emits v2 goal_state without the partial-message gate', () => { adapter.processEvent({ - type: GeminiEventType.GoalState, + type: LlmEventType.GoalState, value: goalSnapshot, cause: 'edit', }); adapter.processEvent({ - type: GeminiEventType.ActiveGoal, + type: LlmEventType.ActiveGoal, value: { condition: 'finish the refactor', iterations: 3, @@ -420,7 +420,7 @@ describe('StreamJsonOutputAdapter', () => { it('should still emit final assistant message', () => { adapter.startAssistantMessage(); adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Text', }); adapter.finalizeAssistantMessage(); @@ -447,11 +447,11 @@ describe('StreamJsonOutputAdapter', () => { it('should append text content from Content events', () => { adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Hello', }); adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: ' World', }); @@ -465,7 +465,7 @@ describe('StreamJsonOutputAdapter', () => { it('should append citation content from Citation events', () => { adapter.processEvent({ - type: GeminiEventType.Citation, + type: LlmEventType.Citation, value: 'Citation text', }); @@ -478,9 +478,9 @@ describe('StreamJsonOutputAdapter', () => { it('should ignore non-string citation values', () => { adapter.processEvent({ - type: GeminiEventType.Citation, + type: LlmEventType.Citation, value: 123, - } as unknown as ServerGeminiStreamEvent); + } as unknown as ServerLlmStreamEvent); const message = adapter.finalizeAssistantMessage(); expect(message.message.content).toHaveLength(0); @@ -488,7 +488,7 @@ describe('StreamJsonOutputAdapter', () => { it('should append thinking from Thought events', () => { adapter.processEvent({ - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: { subject: 'Planning', description: 'Thinking about the task', @@ -506,7 +506,7 @@ describe('StreamJsonOutputAdapter', () => { it('should handle thinking with only subject', () => { adapter.processEvent({ - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: { subject: 'Planning', description: '', @@ -522,7 +522,7 @@ describe('StreamJsonOutputAdapter', () => { it('should preserve whitespace in thinking content (issue #1356)', () => { adapter.processEvent({ - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: { subject: '', description: 'The user just said "Hello"', @@ -545,21 +545,21 @@ describe('StreamJsonOutputAdapter', () => { it('should preserve whitespace when streaming multiple thinking fragments (issue #1356)', () => { // Simulate streaming thinking content in multiple events adapter.processEvent({ - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: { subject: '', description: 'The user just', }, }); adapter.processEvent({ - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: { subject: '', description: ' said "Hello"', }, }); adapter.processEvent({ - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: { subject: '', description: '. This is a simple greeting', @@ -584,7 +584,7 @@ describe('StreamJsonOutputAdapter', () => { it('should append tool use from ToolCallRequest events', () => { adapter.processEvent({ - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-call-1', name: 'test_tool', @@ -606,7 +606,7 @@ describe('StreamJsonOutputAdapter', () => { it('should set stop_reason to tool_use when message contains only tool_use blocks', () => { adapter.processEvent({ - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-call-1', name: 'test_tool', @@ -622,7 +622,7 @@ describe('StreamJsonOutputAdapter', () => { it('should set stop_reason to null when message contains text blocks', () => { adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Some text', }); @@ -632,7 +632,7 @@ describe('StreamJsonOutputAdapter', () => { it('should set stop_reason to null when message contains thinking blocks', () => { adapter.processEvent({ - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: { subject: 'Planning', description: 'Thinking about the task', @@ -645,7 +645,7 @@ describe('StreamJsonOutputAdapter', () => { it('should set stop_reason to tool_use when message contains multiple tool_use blocks', () => { adapter.processEvent({ - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-call-1', name: 'test_tool_1', @@ -655,7 +655,7 @@ describe('StreamJsonOutputAdapter', () => { }, }); adapter.processEvent({ - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-call-2', name: 'test_tool_2', @@ -681,7 +681,7 @@ describe('StreamJsonOutputAdapter', () => { totalTokenCount: 160, }; adapter.processEvent({ - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata, @@ -703,7 +703,7 @@ describe('StreamJsonOutputAdapter', () => { adapter.finalizeAssistantMessage().message.content; adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Should be ignored', }); @@ -720,7 +720,7 @@ describe('StreamJsonOutputAdapter', () => { it('should build and emit a complete assistant message', () => { adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Test response', }); @@ -737,7 +737,7 @@ describe('StreamJsonOutputAdapter', () => { it('should emit message to stdout immediately', () => { adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Test', }); @@ -752,7 +752,7 @@ describe('StreamJsonOutputAdapter', () => { it('should store message in lastAssistantMessage', () => { adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Test', }); @@ -764,7 +764,7 @@ describe('StreamJsonOutputAdapter', () => { it('should return same message on subsequent calls', () => { adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Test', }); @@ -777,11 +777,11 @@ describe('StreamJsonOutputAdapter', () => { it('should split different block types into separate assistant messages', () => { stdoutWriteSpy.mockClear(); adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Text', }); adapter.processEvent({ - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: { subject: 'Thinking', description: 'Thought' }, }); @@ -876,7 +876,7 @@ describe('StreamJsonOutputAdapter', () => { adapter = new StreamJsonOutputAdapter(mockConfig, false); adapter.startAssistantMessage(); adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Response text', }); adapter.finalizeAssistantMessage(); @@ -1297,11 +1297,11 @@ describe('StreamJsonOutputAdapter', () => { it('should not include message_id in content_block events', () => { adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Text', }); adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'More', }); @@ -1329,7 +1329,7 @@ describe('StreamJsonOutputAdapter', () => { it('should identify content_block events by session_id and index', () => { adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Text', }); @@ -1362,15 +1362,15 @@ describe('StreamJsonOutputAdapter', () => { it('should split assistant messages when block types change repeatedly', () => { stdoutWriteSpy.mockClear(); adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Text content', }); adapter.processEvent({ - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: { subject: 'Thinking', description: 'Thought' }, }); adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'More text', }); @@ -1439,15 +1439,15 @@ describe('StreamJsonOutputAdapter', () => { it('should merge consecutive text fragments', () => { adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Hello', }); adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: ' ', }); adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'World', }); diff --git a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts index 53ae0343694..cba028e485c 100644 --- a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts @@ -7,12 +7,12 @@ import { randomUUID } from 'node:crypto'; import type { Config, - ServerGeminiStreamEvent, + ServerLlmStreamEvent, ToolCallRequestInfo, McpToolProgressData, ShellProgressData, } from '@qwen-code/qwen-code-core'; -import { GeminiEventType } from '@qwen-code/qwen-code-core'; +import { LlmEventType } from '@qwen-code/qwen-code-core'; import type { CLIAssistantMessage, CLIMessage, @@ -131,8 +131,8 @@ export class StreamJsonOutputAdapter this.emitMessage(message); } - override processEvent(event: ServerGeminiStreamEvent): void { - if (event.type === GeminiEventType.GoalState) { + override processEvent(event: ServerLlmStreamEvent): void { + if (event.type === LlmEventType.GoalState) { const signature = JSON.stringify(event.value); if (signature === this.lastGoalStateSignature) return; this.lastGoalStateSignature = signature; @@ -153,7 +153,7 @@ export class StreamJsonOutputAdapter // Active goal updates are session-level metadata, not message content. // They intentionally bypass the base finalized guard so late goal state // changes can still reach stream consumers. - if (event.type === GeminiEventType.ActiveGoal) { + if (event.type === LlmEventType.ActiveGoal) { this.emitStreamEventIfEnabled( { type: 'active_goal', diff --git a/packages/cli/src/nonInteractive/session.test.ts b/packages/cli/src/nonInteractive/session.test.ts index 2a389a4d8f6..61fcf4e9db5 100644 --- a/packages/cli/src/nonInteractive/session.test.ts +++ b/packages/cli/src/nonInteractive/session.test.ts @@ -317,16 +317,16 @@ describe('runNonInteractiveStreamJson', () => { return { continueResults, getControlContext: () => controlContext }; } - function createInitializedGeminiClient(historyTail: Content[]) { + function createInitializedLlmClient(historyTail: Content[]) { const getHistoryTail = vi.fn().mockReturnValue(historyTail); - const geminiClient = { + const llmClient = { isInitialized: vi.fn().mockReturnValue(true), getChat: vi.fn().mockReturnValue({ getHistoryTail }), }; config = createConfig({ - getGeminiClient: vi.fn().mockReturnValue(geminiClient), + getLlmClient: vi.fn().mockReturnValue(llmClient), }); - return { geminiClient, getHistoryTail }; + return { llmClient, getHistoryTail }; } it('initializes session and processes initialize control request', async () => { @@ -406,7 +406,7 @@ describe('runNonInteractiveStreamJson', () => { it('rejects continue_last_turn when the Gemini client is not initialized', async () => { const { continueResults } = installContinueDispatch(); config = createConfig({ - getGeminiClient: vi.fn().mockReturnValue(undefined), + getLlmClient: vi.fn().mockReturnValue(undefined), }); const initRequest = createControlRequest('initialize'); const continueRequest = createContinueRequest(); @@ -426,7 +426,7 @@ describe('runNonInteractiveStreamJson', () => { it('rejects continue_last_turn when the last turn ended cleanly', async () => { const { continueResults } = installContinueDispatch(); - const { getHistoryTail } = createInitializedGeminiClient([ + const { getHistoryTail } = createInitializedLlmClient([ { role: 'model', parts: [{ text: 'done' }] }, ]); const initRequest = createControlRequest('initialize'); @@ -448,7 +448,7 @@ describe('runNonInteractiveStreamJson', () => { it('deduplicates continue_last_turn while a continuation is pending or running', async () => { const { continueResults } = installContinueDispatch(); - createInitializedGeminiClient([ + createInitializedLlmClient([ { role: 'user', parts: [{ text: 'resume me' }] }, ]); const initRequest = createControlRequest('initialize'); @@ -497,7 +497,7 @@ describe('runNonInteractiveStreamJson', () => { it('keeps continue_last_turn available after an interrupt with no active turn', async () => { const { continueResults, getControlContext } = installContinueDispatch(); - createInitializedGeminiClient([ + createInitializedLlmClient([ { role: 'user', parts: [{ text: 'resume me' }] }, ]); const initRequest = createControlRequest('initialize'); @@ -577,7 +577,7 @@ describe('runNonInteractiveStreamJson', () => { it('emits a terminal error result when an accepted continuation is abandoned by shutdown', async () => { const { continueResults, getControlContext } = installContinueDispatch(); - createInitializedGeminiClient([ + createInitializedLlmClient([ { role: 'user', parts: [{ text: 'resume me' }] }, ]); const initRequest = createControlRequest('initialize'); @@ -628,7 +628,7 @@ describe('runNonInteractiveStreamJson', () => { it('emits an error result when a scheduled continue turn fails', async () => { const { continueResults } = installContinueDispatch(); - createInitializedGeminiClient([ + createInitializedLlmClient([ { role: 'user', parts: [{ text: 'resume me' }] }, ]); const initRequest = createControlRequest('initialize'); @@ -658,7 +658,7 @@ describe('runNonInteractiveStreamJson', () => { it('flushes recording failures before a session-level error result', async () => { const { continueResults } = installContinueDispatch(); - createInitializedGeminiClient([ + createInitializedLlmClient([ { role: 'user', parts: [{ text: 'resume me' }] }, ]); const order: string[] = []; @@ -667,7 +667,7 @@ describe('runNonInteractiveStreamJson', () => { | undefined; let flushCount = 0; config = createConfig({ - getGeminiClient: vi.fn().mockReturnValue(config.getGeminiClient()), + getLlmClient: vi.fn().mockReturnValue(config.getLlmClient()), onChatRecordingFailure: ( listener: (event: { sessionId: string; error: Error }) => void, ) => { @@ -717,7 +717,7 @@ describe('runNonInteractiveStreamJson', () => { it('does not emit a second result when a failed continue turn already reported one', async () => { const { continueResults } = installContinueDispatch(); - createInitializedGeminiClient([ + createInitializedLlmClient([ { role: 'user', parts: [{ text: 'resume me' }] }, ]); const initRequest = createControlRequest('initialize'); @@ -762,7 +762,7 @@ describe('runNonInteractiveStreamJson', () => { it('emits a continue_turn_failed diagnostic when a continue turn fails after a result', async () => { const { continueResults } = installContinueDispatch(); - createInitializedGeminiClient([ + createInitializedLlmClient([ { role: 'user', parts: [{ text: 'resume me' }] }, ]); const initRequest = createControlRequest('initialize'); diff --git a/packages/cli/src/nonInteractive/session.ts b/packages/cli/src/nonInteractive/session.ts index 192bb3fe75b..a61977d85fb 100644 --- a/packages/cli/src/nonInteractive/session.ts +++ b/packages/cli/src/nonInteractive/session.ts @@ -159,12 +159,12 @@ class Session { debugLogger.debug('[Session] Initializing config'); try { - // gemini.tsx has already emitted warnings known before stream-json + // llm.tsx has already emitted warnings known before stream-json // initialization starts. Keep that snapshot so only warnings produced // by the deferred initialize() call are written here. const emittedWarnings = new Set(this.config.getWarnings()); // Bracket `config.initialize()` with the same profiler checkpoints - // the non-stream-json branch in `gemini.tsx` uses so the + // the non-stream-json branch in `llm.tsx` uses so the // `config_initialize_dur` derived phase shows up in stream-json // startup profiles. `profileCheckpoint` is a no-op when // `QWEN_CODE_PROFILE_STARTUP` is unset, so this adds zero overhead @@ -184,7 +184,7 @@ class Session { // MCP servers settle, so we must explicitly await discovery here — // otherwise the first prompt would see only built-in tools. await this.config.waitForMcpReady(); - // Surface MCP failures on stderr — same rationale as gemini.tsx's + // Surface MCP failures on stderr — same rationale as llm.tsx's // non-interactive branch: per-server errors are caught inside // `discoverAllMcpToolsIncremental` and never reach a TTY otherwise, // so a script using stream-json with broken MCP config would @@ -203,7 +203,7 @@ class Session { } // Finalize the startup profile here so `config_initialize_*` and the // MCP discovery events captured during init/discovery make it into - // the on-disk profile. gemini.tsx's stream-json branch deliberately + // the on-disk profile. llm.tsx's stream-json branch deliberately // skips finalize because the profiler's `finalized` guard would // otherwise suppress every event emitted during the // `Session.ensureConfigInitialized` flow above. @@ -512,15 +512,15 @@ class Session { return { accepted: false, interruption: 'none' }; } - const geminiClient = this.config.getGeminiClient(); - if (!geminiClient || !geminiClient.isInitialized()) { + const llmClient = this.config.getLlmClient(); + if (!llmClient || !llmClient.isInitialized()) { debugLogger.debug( '[Session] continue_last_turn rejected: gemini client is not ready', ); return { accepted: false, interruption: 'none' }; } - const chat = geminiClient.getChat(); + const chat = llmClient.getChat(); const historyTail = chat.getHistoryTailShallow?.(TURN_INTERRUPTION_HISTORY_TAIL_COUNT) ?? chat.getHistoryTail(TURN_INTERRUPTION_HISTORY_TAIL_COUNT); diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index dabf82249e2..15eea5767ab 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -15,7 +15,7 @@ import type { ToolCallRequestInfo, ToolCallResponseInfo, ToolRegistry, - ServerGeminiStreamEvent, + ServerLlmStreamEvent, SessionMetrics, WorkflowApprovalRequestCallback, } from '@qwen-code/qwen-code-core'; @@ -25,7 +25,7 @@ import { isTelemetrySdkInitialized, ToolErrorType, shutdownTelemetry, - GeminiEventType, + LlmEventType, Kind, OutputFormat, uiTelemetryService, @@ -247,7 +247,7 @@ describe('runNonInteractive', () => { let mockShutdownTelemetry: Mock; let processStdoutSpy: MockInstance; let processStderrSpy: MockInstance; - let mockGeminiClient: { + let mockLlmClient: { sendMessageStream: Mock; getChatRecordingService: Mock; getChat: Mock; @@ -317,7 +317,7 @@ describe('runNonInteractive', () => { abortAll: vi.fn(), }; - mockGeminiClient = { + mockLlmClient = { sendMessageStream: vi.fn(), consumePendingMemoryTaskPromises: vi.fn().mockReturnValue([]), recordCompletedToolCall: vi.fn(), @@ -338,7 +338,7 @@ describe('runNonInteractive', () => { mockConfig = { initialize: vi.fn().mockResolvedValue(undefined), getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), - getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient), + getLlmClient: vi.fn().mockReturnValue(mockLlmClient), getChatRecordingService: vi.fn().mockReturnValue({ flush: vi.fn().mockResolvedValue(undefined), finalize: vi.fn().mockResolvedValue(undefined), @@ -392,7 +392,7 @@ describe('runNonInteractive', () => { // return undefined to short-circuit the helper. getResumedSessionData: vi.fn().mockReturnValue(undefined), // Phase D-1: nonInteractiveCli calls this on every prompt to pick - // up the one-shot startup-worktree notice (set by gemini.tsx + // up the one-shot startup-worktree notice (set by llm.tsx // when --worktree was passed). These tests don't exercise the // --worktree flag, so return null to short-circuit injection // and let the resume-restore branch run. @@ -483,8 +483,8 @@ describe('runNonInteractive', () => { } async function* createStreamFromEvents( - events: ServerGeminiStreamEvent[], - ): AsyncGenerator { + events: ServerLlmStreamEvent[], + ): AsyncGenerator { for (const event of events) { yield event; } @@ -584,10 +584,10 @@ describe('runNonInteractive', () => { function mockFinishedGoalWorker(): void { vi.spyOn(goalRuntime, 'finishTurn').mockResolvedValue(undefined); - mockGeminiClient.sendMessageStream.mockImplementation(() => + mockLlmClient.sendMessageStream.mockImplementation(() => createStreamFromEvents([ { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 0 }, @@ -613,7 +613,7 @@ describe('runNonInteractive', () => { ); expect(exitCode).toBe(0); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes( + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes( testCase.expectedWorkers, ); expect(processStdoutSpy).toHaveBeenCalledWith( @@ -656,7 +656,7 @@ describe('runNonInteractive', () => { ); expect(exitCode).toBe(0); - expect(mockGeminiClient.sendMessageStream).not.toHaveBeenCalled(); + expect(mockLlmClient.sendMessageStream).not.toHaveBeenCalled(); expect(processStdoutSpy).toHaveBeenCalledWith(`${expectedText}\n`); }, ); @@ -676,7 +676,7 @@ describe('runNonInteractive', () => { ); expect(exitCode).toBe(1); - expect(mockGeminiClient.sendMessageStream).not.toHaveBeenCalled(); + expect(mockLlmClient.sendMessageStream).not.toHaveBeenCalled(); }); it('runs resume with the exact permit scheduled by Core', async () => { @@ -692,9 +692,8 @@ describe('runNonInteractive', () => { 'goal-resume-exact', ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledOnce(); - const [parts, , , options] = - mockGeminiClient.sendMessageStream.mock.calls[0]!; + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledOnce(); + const [parts, , , options] = mockLlmClient.sendMessageStream.mock.calls[0]!; expect(parts[0]?.text).toContain('Continue working on the active Goal.'); expect(parts[0]?.text).toContain( 'Runtime continuation context: existing goal', @@ -738,8 +737,8 @@ describe('runNonInteractive', () => { 'goal-runtime-feedback', ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledOnce(); - const [parts] = mockGeminiClient.sendMessageStream.mock.calls[0]!; + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledOnce(); + const [parts] = mockLlmClient.sendMessageStream.mock.calls[0]!; expect(parts[0]?.text).toContain( 'Verifier feedback: Need independent evidence', ); @@ -754,7 +753,7 @@ describe('runNonInteractive', () => { responseParts: [{ text: 'tool response' }], }); let requestCount = 0; - mockGeminiClient.sendMessageStream.mockImplementation( + mockLlmClient.sendMessageStream.mockImplementation( ( _parts: Part[], _signal: AbortSignal, @@ -765,7 +764,7 @@ describe('runNonInteractive', () => { if (requestCount === 1) { return createStreamFromEvents([ { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'goal-tool-1', name: 'testTool', @@ -779,7 +778,7 @@ describe('runNonInteractive', () => { } return createStreamFromEvents([ { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 0 }, @@ -796,9 +795,9 @@ describe('runNonInteractive', () => { 'goal-resume-tool-result', ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); - const firstOptions = mockGeminiClient.sendMessageStream.mock.calls[0]![3]; - const secondOptions = mockGeminiClient.sendMessageStream.mock.calls[1]![3]; + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(2); + const firstOptions = mockLlmClient.sendMessageStream.mock.calls[0]![3]; + const secondOptions = mockLlmClient.sendMessageStream.mock.calls[1]![3]; expect(secondOptions).toMatchObject({ type: SendMessageType.ToolResult, goalPermit: firstOptions.goalPermit, @@ -835,7 +834,7 @@ describe('runNonInteractive', () => { }; }); let requestCount = 0; - mockGeminiClient.sendMessageStream.mockImplementation( + mockLlmClient.sendMessageStream.mockImplementation( ( _parts: Part[], _signal: AbortSignal, @@ -846,7 +845,7 @@ describe('runNonInteractive', () => { if (requestCount === 1) { return createStreamFromEvents([ { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'goal-tool-with-teammate', name: 'testTool', @@ -860,7 +859,7 @@ describe('runNonInteractive', () => { } return createStreamFromEvents([ { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 0 }, @@ -877,10 +876,10 @@ describe('runNonInteractive', () => { 'goal-teammate-handoff', ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); - const firstOptions = mockGeminiClient.sendMessageStream.mock.calls[0]![3]; + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(2); + const firstOptions = mockLlmClient.sendMessageStream.mock.calls[0]![3]; const [, , secondPromptId, secondOptions] = - mockGeminiClient.sendMessageStream.mock.calls[1]!; + mockLlmClient.sendMessageStream.mock.calls[1]!; expect(secondPromptId).toBe('goal-teammate-handoff/teammate/2'); expect(secondOptions).toMatchObject({ type: SendMessageType.Teammate, @@ -892,7 +891,7 @@ describe('runNonInteractive', () => { promptId: 'goal-teammate-handoff', }); expect(endInteractionSpanSpy.mock.invocationCallOrder[0]).toBeLessThan( - mockGeminiClient.sendMessageStream.mock.invocationCallOrder[1]!, + mockLlmClient.sendMessageStream.mock.invocationCallOrder[1]!, ); }); @@ -911,7 +910,7 @@ describe('runNonInteractive', () => { errorType: undefined, terminateTurn: true, }); - mockGeminiClient.sendMessageStream.mockImplementation( + mockLlmClient.sendMessageStream.mockImplementation( ( _parts: Part[], _signal: AbortSignal, @@ -920,7 +919,7 @@ describe('runNonInteractive', () => { ) => createStreamFromEvents([ { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'update-goal-terminal', name: 'update_goal', @@ -941,8 +940,8 @@ describe('runNonInteractive', () => { ); expect(exitCode).toBe(0); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledOnce(); - expect(mockGeminiClient.addHistory).toHaveBeenCalledWith({ + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledOnce(); + expect(mockLlmClient.addHistory).toHaveBeenCalledWith({ role: 'user', parts: [{ text: 'proposal recorded' }], }); @@ -970,7 +969,7 @@ describe('runNonInteractive', () => { responseParts: [{ text: 'proposal recorded' }], terminateTurn: true, }); - mockGeminiClient.sendMessageStream.mockImplementation( + mockLlmClient.sendMessageStream.mockImplementation( ( _parts: Part[], _signal: AbortSignal, @@ -979,7 +978,7 @@ describe('runNonInteractive', () => { ) => createStreamFromEvents([ { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'update-goal-state-stream', name: 'update_goal', @@ -1030,11 +1029,11 @@ describe('runNonInteractive', () => { finalize: vi.fn().mockResolvedValue(undefined), })), }); - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'still working' }, + { type: LlmEventType.Content, value: 'still working' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 0 }, @@ -1066,11 +1065,11 @@ describe('runNonInteractive', () => { mockCoreExecuteToolCall.mockResolvedValue({ responseParts: [{ text: 'tool response' }], }); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents([ { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'goal-unlimited-turns-tool', name: 'testTool', @@ -1084,7 +1083,7 @@ describe('runNonInteractive', () => { .mockReturnValueOnce( createStreamFromEvents([ { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 0 }, @@ -1101,7 +1100,7 @@ describe('runNonInteractive', () => { ); expect(exitCode).toBe(0); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(2); expect(mockCoreExecuteToolCall).toHaveBeenCalledOnce(); }); @@ -1124,7 +1123,7 @@ describe('runNonInteractive', () => { ), ).rejects.toThrow('process.exit(53) called'); - expect(mockGeminiClient.sendMessageStream).not.toHaveBeenCalled(); + expect(mockLlmClient.sendMessageStream).not.toHaveBeenCalled(); expect(goalStatusAtExit).toBe('paused'); }); @@ -1134,10 +1133,10 @@ describe('runNonInteractive', () => { await prepareGoalState('paused'); vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0); vi.mocked(mockConfig.getMaxToolCalls).mockReturnValue(0); - mockGeminiClient.sendMessageStream.mockReturnValueOnce( + mockLlmClient.sendMessageStream.mockReturnValueOnce( createStreamFromEvents([ { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'goal-explicit-budget-tool', name: 'testTool', @@ -1196,9 +1195,9 @@ describe('runNonInteractive', () => { interactionOpen = false; } }); - mockGeminiClient.sendMessageStream.mockReturnValueOnce( + mockLlmClient.sendMessageStream.mockReturnValueOnce( (async function* () { - yield { type: GeminiEventType.Content, value: 'partial response' }; + yield { type: LlmEventType.Content, value: 'partial response' }; if (!runAbortController.signal.aborted) { await new Promise((_, reject) => { runAbortController.signal.addEventListener( @@ -1262,10 +1261,10 @@ describe('runNonInteractive', () => { responseParts: [{ text: 'proposal recorded' }], terminateTurn: true, }); - mockGeminiClient.sendMessageStream.mockReturnValueOnce( + mockLlmClient.sendMessageStream.mockReturnValueOnce( createStreamFromEvents([ { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'goal-wall-budget', name: 'update_goal', @@ -1317,7 +1316,7 @@ describe('runNonInteractive', () => { expect(beginTurn.mock.invocationCallOrder[0]).toBeLessThan( vi.mocked(mockConfig.bindGoalTurnHost).mock.invocationCallOrder[0]!, ); - expect(mockGeminiClient.sendMessageStream.mock.calls[0]![3]).toMatchObject({ + expect(mockLlmClient.sendMessageStream.mock.calls[0]![3]).toMatchObject({ type: SendMessageType.UserQuery, goalOrigin: 'user', goalTurnKey: 'goal-real-user', @@ -1348,12 +1347,12 @@ describe('runNonInteractive', () => { await vi.waitFor(() => expect(beginTurn).toHaveBeenCalledWith('goal-queued-user'), ); - expect(mockGeminiClient.sendMessageStream).not.toHaveBeenCalled(); + expect(mockLlmClient.sendMessageStream).not.toHaveBeenCalled(); await finishOccupyingTurn(occupyingPermit!); await run; - const sendOptions = mockGeminiClient.sendMessageStream.mock.calls[0]![3]; + const sendOptions = mockLlmClient.sendMessageStream.mock.calls[0]![3]; expect(sendOptions).toMatchObject({ type: SendMessageType.UserQuery, goalOrigin: 'user', @@ -1390,16 +1389,16 @@ describe('runNonInteractive', () => { .map(({ event }) => event?.type) .filter((type) => type === 'goal_state' || type === 'active_goal'); expect(goalEventTypes).toEqual(['goal_state', 'active_goal']); - expect(mockGeminiClient.sendMessageStream).not.toHaveBeenCalled(); + expect(mockLlmClient.sendMessageStream).not.toHaveBeenCalled(); }); const headlessImageParts: Part[] = [ { text: 'inspect this image' }, { inlineData: { mimeType: 'image/png', data: 'AAAA' } }, ]; - const finishedEvents: ServerGeminiStreamEvent[] = [ + const finishedEvents: ServerLlmStreamEvent[] = [ { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 }, @@ -1441,15 +1440,15 @@ describe('runNonInteractive', () => { it('should process input and write text output', async () => { setupMetricsMock(); - const events: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'Hello' }, - { type: GeminiEventType.Content, value: ' World' }, + const events: ServerLlmStreamEvent[] = [ + { type: LlmEventType.Content, value: 'Hello' }, + { type: LlmEventType.Content, value: ' World' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); @@ -1460,7 +1459,7 @@ describe('runNonInteractive', () => { 'prompt-id-1', ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledWith( [{ text: 'Test input' }], expect.any(AbortSignal), 'prompt-id-1', @@ -1485,8 +1484,8 @@ describe('runNonInteractive', () => { permission: { handleWorkflowApproval }, } as unknown as ControlService; const approvalSignal = new AbortController().signal; - mockGeminiClient.sendMessageStream.mockImplementation( - async function* (): AsyncGenerator { + mockLlmClient.sendMessageStream.mockImplementation( + async function* (): AsyncGenerator { const callback = setApprovalRequestCallback.mock.calls[0]?.[0] as | WorkflowApprovalRequestCallback | undefined; @@ -1501,7 +1500,7 @@ describe('runNonInteractive', () => { approvalSignal, ); yield { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 0 }, @@ -1529,10 +1528,10 @@ describe('runNonInteractive', () => { mockConfig.getWorkflowRunRegistry = vi.fn().mockReturnValue({ setApprovalRequestCallback, }); - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents([ { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 0 }, @@ -1553,10 +1552,10 @@ describe('runNonInteractive', () => { vi.mocked(mockConfig.consumePendingRecoveredAgentsNotice).mockReturnValue( 'Restored 2 background agents from the previous session.', ); - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents([ { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, }, ]), @@ -1570,7 +1569,7 @@ describe('runNonInteractive', () => { ); expect(mockConfig.consumePendingRecoveredAgentsNotice).toHaveBeenCalled(); - const [request] = mockGeminiClient.sendMessageStream.mock.calls[0]!; + const [request] = mockLlmClient.sendMessageStream.mock.calls[0]!; // The notice is prepended as a system-reminder ahead of the user prompt. expect(request).toEqual([ { @@ -1594,7 +1593,7 @@ describe('runNonInteractive', () => { vi.mocked(mockConfig.consumePendingRecoveredAgentsNotice).mockReturnValue( 'Restored 2 background agents from the previous session.', ); - mockGeminiClient.getChat = vi.fn(() => ({ + mockLlmClient.getChat = vi.fn(() => ({ getDebugResponses: mockGetDebugResponses, getHistory: vi.fn().mockReturnValue([ { @@ -1603,10 +1602,10 @@ describe('runNonInteractive', () => { }, ]), })); - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents([ { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, }, ]), @@ -1621,7 +1620,7 @@ describe('runNonInteractive', () => { expect( mockConfig.consumePendingRecoveredAgentsNotice, ).not.toHaveBeenCalled(); - const [request] = mockGeminiClient.sendMessageStream.mock.calls[0]!; + const [request] = mockLlmClient.sendMessageStream.mock.calls[0]!; expect(request).toEqual([ { functionResponse: { @@ -1646,8 +1645,8 @@ describe('runNonInteractive', () => { vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.YOLO); vi.mocked(mockConfig.getTeamManager).mockReturnValue(teamManager as never); let emittedApproval = false; - mockGeminiClient.sendMessageStream.mockImplementation( - async function* (): AsyncGenerator { + mockLlmClient.sendMessageStream.mockImplementation( + async function* (): AsyncGenerator { if (!emittedApproval) { emittedApproval = true; teamEvents.emit(TeamEventType.TEAMMATE_APPROVAL_REQUEST, { @@ -1665,7 +1664,7 @@ describe('runNonInteractive', () => { }); } yield { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 0 }, @@ -1707,8 +1706,8 @@ describe('runNonInteractive', () => { vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.DEFAULT); vi.mocked(mockConfig.getTeamManager).mockReturnValue(teamManager as never); let emittedApproval = false; - mockGeminiClient.sendMessageStream.mockImplementation( - async function* (): AsyncGenerator { + mockLlmClient.sendMessageStream.mockImplementation( + async function* (): AsyncGenerator { if (!emittedApproval) { emittedApproval = true; teamEvents.emit(TeamEventType.TEAMMATE_APPROVAL_REQUEST, { @@ -1726,7 +1725,7 @@ describe('runNonInteractive', () => { }); } yield { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 0 }, @@ -1766,7 +1765,7 @@ describe('runNonInteractive', () => { // The orphan strip + restore is owned by the Retry send path in // client.ts (covered by client.test.ts); here we only assert the // continuation hands off to that path with Retry semantics. - mockGeminiClient.getChat = vi.fn(() => ({ + mockLlmClient.getChat = vi.fn(() => ({ getDebugResponses: mockGetDebugResponses, getHistory: vi .fn() @@ -1774,10 +1773,10 @@ describe('runNonInteractive', () => { { role: 'user', parts: [{ text: 'do the thing' }] }, ]), })); - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents([ { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, }, ]), @@ -1787,7 +1786,7 @@ describe('runNonInteractive', () => { continueInterrupted: true, }); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledWith( [{ text: 'do the thing' }], expect.any(AbortSignal), 'prompt-c1', @@ -1798,8 +1797,8 @@ describe('runNonInteractive', () => { it('adds plan mode reminders to an interrupted prompt replay', async () => { setupMetricsMock(); mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.PLAN); - mockGeminiClient.stripOrphanedUserEntriesFromHistory = vi.fn(); - mockGeminiClient.getChat = vi.fn(() => ({ + mockLlmClient.stripOrphanedUserEntriesFromHistory = vi.fn(); + mockLlmClient.getChat = vi.fn(() => ({ getDebugResponses: mockGetDebugResponses, getHistory: vi .fn() @@ -1807,10 +1806,10 @@ describe('runNonInteractive', () => { { role: 'user', parts: [{ text: 'do the thing' }] }, ]), })); - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents([ { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, }, ]), @@ -1821,7 +1820,7 @@ describe('runNonInteractive', () => { }); const [request, , , options] = - mockGeminiClient.sendMessageStream.mock.calls[0]!; + mockLlmClient.sendMessageStream.mock.calls[0]!; expect(options).toEqual( expect.objectContaining({ type: SendMessageType.Retry }), ); @@ -1833,7 +1832,7 @@ describe('runNonInteractive', () => { it('closes dangling tool calls with synthesized ToolResult parts', async () => { setupMetricsMock(); - mockGeminiClient.getChat = vi.fn(() => ({ + mockLlmClient.getChat = vi.fn(() => ({ getDebugResponses: mockGetDebugResponses, getHistory: vi.fn().mockReturnValue([ { @@ -1842,10 +1841,10 @@ describe('runNonInteractive', () => { }, ]), })); - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents([ { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, }, ]), @@ -1856,7 +1855,7 @@ describe('runNonInteractive', () => { }); const [request, , , options] = - mockGeminiClient.sendMessageStream.mock.calls[0]!; + mockLlmClient.sendMessageStream.mock.calls[0]!; expect(options).toEqual( expect.objectContaining({ type: SendMessageType.ToolResult }), ); @@ -1874,7 +1873,7 @@ describe('runNonInteractive', () => { it('adds plan mode reminders to a continued tool result without moving function responses', async () => { setupMetricsMock(); mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.PLAN); - mockGeminiClient.getChat = vi.fn(() => ({ + mockLlmClient.getChat = vi.fn(() => ({ getDebugResponses: mockGetDebugResponses, getHistory: vi.fn().mockReturnValue([ { @@ -1883,10 +1882,10 @@ describe('runNonInteractive', () => { }, ]), })); - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents([ { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, }, ]), @@ -1903,7 +1902,7 @@ describe('runNonInteractive', () => { ); const [request, , , options] = - mockGeminiClient.sendMessageStream.mock.calls[0]!; + mockLlmClient.sendMessageStream.mock.calls[0]!; expect(options).toEqual( expect.objectContaining({ type: SendMessageType.ToolResult }), ); @@ -1921,7 +1920,7 @@ describe('runNonInteractive', () => { it('is a no-op when the last turn ended cleanly', async () => { setupMetricsMock(); - mockGeminiClient.getChat = vi.fn(() => ({ + mockLlmClient.getChat = vi.fn(() => ({ getDebugResponses: mockGetDebugResponses, getHistory: vi .fn() @@ -1932,7 +1931,7 @@ describe('runNonInteractive', () => { continueInterrupted: true, }); - expect(mockGeminiClient.sendMessageStream).not.toHaveBeenCalled(); + expect(mockLlmClient.sendMessageStream).not.toHaveBeenCalled(); }); }); @@ -1946,15 +1945,15 @@ describe('runNonInteractive', () => { .spyOn(process.stdout, 'destroy') .mockReturnValue(process.stdout); - mockGeminiClient.sendMessageStream.mockImplementation( - async function* mockStream(): AsyncGenerator { + mockLlmClient.sendMessageStream.mockImplementation( + async function* mockStream(): AsyncGenerator { process.stdout.emit( 'error', Object.assign(new Error('EPIPE'), { code: 'EPIPE' }), ); - yield { type: GeminiEventType.Content, value: 'Hello' }; + yield { type: LlmEventType.Content, value: 'Hello' }; yield { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 0 }, @@ -1970,8 +1969,8 @@ describe('runNonInteractive', () => { it('returns non-zero and skips pending tool calls after loop detection', async () => { setupMetricsMock(); - const toolCallEvent: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const toolCallEvent: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-1', name: 'testTool', @@ -1980,14 +1979,14 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-loop-detected', }, }; - const events: ServerGeminiStreamEvent[] = [ + const events: ServerLlmStreamEvent[] = [ toolCallEvent, { - type: GeminiEventType.LoopDetected, + type: LlmEventType.LoopDetected, value: { loopType: LoopType.TURN_TOOL_CALL_CAP }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); @@ -2008,8 +2007,8 @@ describe('runNonInteractive', () => { it('shows the always-on hint (not the skipLoopDetection escape) for a consecutive-identical halt', async () => { setupMetricsMock(); - const toolCallEvent: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const toolCallEvent: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-1', name: 'run_shell_command', @@ -2018,14 +2017,14 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-consecutive-loop', }, }; - const events: ServerGeminiStreamEvent[] = [ + const events: ServerLlmStreamEvent[] = [ toolCallEvent, { - type: GeminiEventType.LoopDetected, + type: LlmEventType.LoopDetected, value: { loopType: LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); @@ -2054,8 +2053,8 @@ describe('runNonInteractive', () => { it('shows the skipLoopDetection escape hint for a heuristic loop type', async () => { setupMetricsMock(); - const toolCallEvent: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const toolCallEvent: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-1', name: 'run_shell_command', @@ -2064,14 +2063,14 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-heuristic-loop', }, }; - const events: ServerGeminiStreamEvent[] = [ + const events: ServerLlmStreamEvent[] = [ toolCallEvent, { - type: GeminiEventType.LoopDetected, + type: LlmEventType.LoopDetected, value: { loopType: LoopType.REPETITIVE_THOUGHTS }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); @@ -2098,13 +2097,13 @@ describe('runNonInteractive', () => { it('describes a chanting halt as output-or-reasoning repetition', async () => { setupMetricsMock(); - const events: ServerGeminiStreamEvent[] = [ + const events: ServerLlmStreamEvent[] = [ { - type: GeminiEventType.LoopDetected, + type: LlmEventType.LoopDetected, value: { loopType: LoopType.CHANTING_IDENTICAL_SENTENCES }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); @@ -2129,14 +2128,14 @@ describe('runNonInteractive', () => { it('shows the maxToolCallsPerTurn hint when the per-turn cap halts the run', async () => { setupMetricsMock(); - const events: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'Partial work' }, + const events: ServerLlmStreamEvent[] = [ + { type: LlmEventType.Content, value: 'Partial work' }, { - type: GeminiEventType.LoopDetected, + type: LlmEventType.LoopDetected, value: { loopType: LoopType.TURN_TOOL_CALL_CAP }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); @@ -2166,14 +2165,14 @@ describe('runNonInteractive', () => { it('marks JSON output as an error when loop detection halts the run', async () => { (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); setupMetricsMock(); - const events: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'Partial work' }, + const events: ServerLlmStreamEvent[] = [ + { type: LlmEventType.Content, value: 'Partial work' }, { - type: GeminiEventType.LoopDetected, + type: LlmEventType.LoopDetected, value: { loopType: LoopType.TURN_TOOL_CALL_CAP }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); @@ -2205,11 +2204,11 @@ describe('runNonInteractive', () => { it('finalizes and reports recording failure before the JSON terminal result', async () => { (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); setupMetricsMock(); - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Answer' }, + { type: LlmEventType.Content, value: 'Answer' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, }, ]), @@ -2286,11 +2285,11 @@ describe('runNonInteractive', () => { OutputFormat.STREAM_JSON, ); setupMetricsMock(); - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Answer' }, + { type: LlmEventType.Content, value: 'Answer' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, }, ]), @@ -2321,8 +2320,8 @@ describe('runNonInteractive', () => { it('should handle a single tool call and respond', async () => { setupMetricsMock(); - const toolCallEvent: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const toolCallEvent: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-1', name: 'testTool', @@ -2337,16 +2336,16 @@ describe('runNonInteractive', () => { executionStatus: 'success', }); - const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent]; - const secondCallEvents: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'Final answer' }, + const firstCallEvents: ServerLlmStreamEvent[] = [toolCallEvent]; + const secondCallEvents: ServerLlmStreamEvent[] = [ + { type: LlmEventType.Content, value: 'Final answer' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, }, ]; - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); @@ -2357,7 +2356,7 @@ describe('runNonInteractive', () => { 'prompt-id-2', ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(2); expect(mockCoreExecuteToolCall).toHaveBeenCalledWith( mockConfig, expect.objectContaining({ name: 'testTool' }), @@ -2367,7 +2366,7 @@ describe('runNonInteractive', () => { }), ); // Verify first call has type: UserQuery - expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenNthCalledWith( 1, [{ text: 'Use a tool' }], expect.any(AbortSignal), @@ -2379,7 +2378,7 @@ describe('runNonInteractive', () => { }, ); // Verify second call (after tool execution) has type: ToolResult - expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenNthCalledWith( 2, [{ text: 'Tool response' }], expect.any(AbortSignal), @@ -2388,14 +2387,12 @@ describe('runNonInteractive', () => { ); expect(processStdoutSpy).toHaveBeenCalledWith('Final answer\n'); // Verify recordCompletedToolCall is called with the tool name and args. - expect(mockGeminiClient.recordCompletedToolCall).toHaveBeenCalledWith( + expect(mockLlmClient.recordCompletedToolCall).toHaveBeenCalledWith( 'testTool', { arg1: 'value1' }, ); // Verify consumePendingMemoryTaskPromises is called at the end of the session. - expect( - mockGeminiClient.consumePendingMemoryTaskPromises, - ).toHaveBeenCalled(); + expect(mockLlmClient.consumePendingMemoryTaskPromises).toHaveBeenCalled(); }); it('uses a tool-selected full-turn model for the next request', async () => { @@ -2416,11 +2413,11 @@ describe('runNonInteractive', () => { }; }, ); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents([ { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-image', name: 'screenshot_tool', @@ -2430,7 +2427,7 @@ describe('runNonInteractive', () => { }, }, { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'skill-after-image', name: 'skill_tool', @@ -2443,9 +2440,9 @@ describe('runNonInteractive', () => { ) .mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Image understood' }, + { type: LlmEventType.Content, value: 'Image understood' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 }, @@ -2461,7 +2458,7 @@ describe('runNonInteractive', () => { 'prompt-tool-image', ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenNthCalledWith( 2, [{ text: 'Tool response with image' }, { text: 'Skill response' }], expect.any(AbortSignal), @@ -2494,11 +2491,11 @@ describe('runNonInteractive', () => { return { responseParts: [{ text: 'other' }], modelOverride: undefined }; }, ); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents([ { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-image-a', name: 'screenshot_tool', @@ -2508,7 +2505,7 @@ describe('runNonInteractive', () => { }, }, { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-image-b', name: 'screenshot_tool', @@ -2521,9 +2518,9 @@ describe('runNonInteractive', () => { ) .mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Image understood' }, + { type: LlmEventType.Content, value: 'Image understood' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 }, @@ -2541,7 +2538,7 @@ describe('runNonInteractive', () => { expect(accepted['a']).toBe(true); expect(accepted['b']).toBe(false); - expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenNthCalledWith( 2, [{ text: 'first image' }, { text: 'second image' }], expect.any(AbortSignal), @@ -2596,11 +2593,11 @@ describe('runNonInteractive', () => { }, ); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents([ { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-main', name: 'some_tool', @@ -2613,9 +2610,9 @@ describe('runNonInteractive', () => { ) .mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Main done' }, + { type: LlmEventType.Content, value: 'Main done' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 }, @@ -2626,7 +2623,7 @@ describe('runNonInteractive', () => { .mockReturnValueOnce( createStreamFromEvents([ { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'drain-tool-a', name: 'screenshot_tool', @@ -2636,7 +2633,7 @@ describe('runNonInteractive', () => { }, }, { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'drain-tool-b', name: 'screenshot_tool', @@ -2649,9 +2646,9 @@ describe('runNonInteractive', () => { ) .mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Drain done' }, + { type: LlmEventType.Content, value: 'Drain done' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 }, @@ -2669,7 +2666,7 @@ describe('runNonInteractive', () => { expect(accepted['a']).toBe(true); expect(accepted['b']).toBe(false); - expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenNthCalledWith( 4, [{ text: 'drain image a' }, { text: 'drain image b' }], expect.any(AbortSignal), @@ -2679,10 +2676,10 @@ describe('runNonInteractive', () => { }); describe('parallel tool execution', () => { - const finishTurn: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'done' }, + const finishTurn: ServerLlmStreamEvent[] = [ + { type: LlmEventType.Content, value: 'done' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, }, ]; @@ -2691,9 +2688,9 @@ describe('runNonInteractive', () => { ids: string[], name: string, promptId: string, - ): ServerGeminiStreamEvent[] { + ): ServerLlmStreamEvent[] { return ids.map((callId) => ({ - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId, name, @@ -2750,7 +2747,7 @@ describe('runNonInteractive', () => { }), ); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents([ ...toolCallEvents( @@ -2784,7 +2781,7 @@ describe('runNonInteractive', () => { callId: 'enter-plan', name: ToolNames.ENTER_PLAN_MODE, }); - const nextTurnParts = mockGeminiClient.sendMessageStream.mock + const nextTurnParts = mockLlmClient.sendMessageStream.mock .calls[1][0] as Part[]; expect(nextTurnParts.map((part) => part.functionResponse?.id)).toEqual([ 'write-before-entry', @@ -2843,7 +2840,7 @@ describe('runNonInteractive', () => { }, ); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents( toolCallEvents( @@ -2887,7 +2884,7 @@ describe('runNonInteractive', () => { }), ); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents( toolCallEvents(['a', 'b', 'c'], 'read', 'p-order'), @@ -2907,7 +2904,7 @@ describe('runNonInteractive', () => { // The next model turn must receive the tool responses in the original // request order a, b, c — not the completion order c, a, b. - const nextTurnParts = mockGeminiClient.sendMessageStream.mock + const nextTurnParts = mockLlmClient.sendMessageStream.mock .calls[1][0] as Part[]; const ids = nextTurnParts .map((part) => part.functionResponse?.id) @@ -2954,7 +2951,7 @@ describe('runNonInteractive', () => { persistedOutputFiles: [], }), ); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents(toolCallEvents(['a', 'b'], 'read', 'p-cap')), ) @@ -2962,7 +2959,7 @@ describe('runNonInteractive', () => { await runNonInteractive(mockConfig, mockSettings, 'go', 'p-cap'); - const nextTurnParts = mockGeminiClient.sendMessageStream.mock + const nextTurnParts = mockLlmClient.sendMessageStream.mock .calls[1][0] as Part[]; const total = nextTurnParts.reduce((sum, part) => { const output = part.functionResponse?.response?.['output']; @@ -2995,7 +2992,7 @@ describe('runNonInteractive', () => { }), ); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents(toolCallEvents(['e1', 'e2'], 'edit', 'p-seq')), ) @@ -3025,7 +3022,7 @@ describe('runNonInteractive', () => { responseParts: [{ text: 'r' }], }); - mockGeminiClient.sendMessageStream.mockReturnValueOnce( + mockLlmClient.sendMessageStream.mockReturnValueOnce( createStreamFromEvents( toolCallEvents(['t1', 't2', 't3'], 'read', 'p-budget'), ), @@ -3079,7 +3076,7 @@ describe('runNonInteractive', () => { }), ); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents([ ...toolCallEvents(['r1', 'r2'], 'read', 'p-mixed'), @@ -3141,7 +3138,7 @@ describe('runNonInteractive', () => { }), ); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents( toolCallEvents(['c1', 'c2', 'c3', 'c4'], 'read', 'p-cap'), @@ -3210,7 +3207,7 @@ describe('runNonInteractive', () => { }, ); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents( toolCallEvents(['s1', 's2'], 'search_file_content', 'p-alias'), @@ -3231,8 +3228,8 @@ describe('runNonInteractive', () => { it('should ignore duplicate provider tool-call ids across rounds', async () => { setupMetricsMock(); vi.mocked(mockConfig.getMaxToolCalls).mockReturnValue(1); - const toolCallEvent: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const toolCallEvent: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-1', providerCallId: 'tool-1', @@ -3245,14 +3242,14 @@ describe('runNonInteractive', () => { const toolResponse: Part[] = [{ text: 'Tool response' }]; mockCoreExecuteToolCall.mockResolvedValue({ responseParts: toolResponse }); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) .mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Final answer' }, + { type: LlmEventType.Content, value: 'Final answer' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 }, @@ -3268,11 +3265,11 @@ describe('runNonInteractive', () => { 'prompt-id-dup', ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(3); + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(3); expect(mockCoreExecuteToolCall).toHaveBeenCalledTimes(1); - expect(mockGeminiClient.recordCompletedToolCall).toHaveBeenCalledTimes(1); + expect(mockLlmClient.recordCompletedToolCall).toHaveBeenCalledTimes(1); - const duplicateParts = mockGeminiClient.sendMessageStream.mock.calls[2][0]; + const duplicateParts = mockLlmClient.sendMessageStream.mock.calls[2][0]; expect(duplicateParts[0].functionResponse?.response?.['error']).toContain( 'Duplicate provider tool call id "tool-1"', ); @@ -3282,8 +3279,8 @@ describe('runNonInteractive', () => { it('should stop repeated duplicate provider tool-call responses', async () => { setupMetricsMock(); vi.mocked(mockConfig.getMaxToolCalls).mockReturnValue(1); - const toolCallEvent: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const toolCallEvent: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-1', providerCallId: 'tool-1', @@ -3293,8 +3290,8 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-dup-loop', }, }; - const freshToolCallEvent: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const freshToolCallEvent: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-2', providerCallId: 'tool-2', @@ -3308,7 +3305,7 @@ describe('runNonInteractive', () => { responseParts: [{ text: 'Tool response' }], }); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) .mockReturnValueOnce( @@ -3323,11 +3320,11 @@ describe('runNonInteractive', () => { ); expect(exitCode).toBe(1); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(3); + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(3); expect(mockCoreExecuteToolCall).toHaveBeenCalledTimes(1); - expect(mockGeminiClient.recordCompletedToolCall).toHaveBeenCalledTimes(1); + expect(mockLlmClient.recordCompletedToolCall).toHaveBeenCalledTimes(1); - const duplicateParts = mockGeminiClient.sendMessageStream.mock.calls[2][0]; + const duplicateParts = mockLlmClient.sendMessageStream.mock.calls[2][0]; expect(duplicateParts[0].functionResponse?.response?.['error']).toContain( 'Duplicate provider tool call id "tool-1"', ); @@ -3348,7 +3345,7 @@ describe('runNonInteractive', () => { it('should stop repeated duplicate provider tool-call responses from drain items', async () => { setupMetricsMock(); - mockGeminiClient.getHistoryToolCallFingerprints.mockReturnValue( + mockLlmClient.getHistoryToolCallFingerprints.mockReturnValue( new Map([ ['tool-drain', getToolCallFingerprint('testTool', { arg1: 'value1' })], ]), @@ -3372,8 +3369,8 @@ describe('runNonInteractive', () => { }); }); - const duplicateToolCallEvent: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const duplicateToolCallEvent: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-drain__qwen_dup_2', providerCallId: 'tool-drain', @@ -3383,8 +3380,8 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-drain-dup-loop', }, }; - const freshToolCallEvent: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const freshToolCallEvent: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-fresh', providerCallId: 'tool-fresh', @@ -3395,12 +3392,12 @@ describe('runNonInteractive', () => { }, }; - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Monitor launched.' }, + { type: LlmEventType.Content, value: 'Monitor launched.' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 2 }, @@ -3421,16 +3418,16 @@ describe('runNonInteractive', () => { ); expect(exitCode).toBe(1); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(3); + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(3); expect(mockCoreExecuteToolCall).not.toHaveBeenCalled(); - const drainPromptIds = mockGeminiClient.sendMessageStream.mock.calls + const drainPromptIds = mockLlmClient.sendMessageStream.mock.calls .slice(1) .map((call) => call[2]); expect(new Set(drainPromptIds)).toEqual( new Set(['prompt-id-drain-dup-loop/automatic/2']), ); - const duplicateParts = mockGeminiClient.sendMessageStream.mock + const duplicateParts = mockLlmClient.sendMessageStream.mock .calls[2][0] as Part[]; expect(duplicateParts[0].functionResponse?.response?.['error']).toContain( 'Duplicate provider tool call id "tool-drain"', @@ -3442,7 +3439,7 @@ describe('runNonInteractive', () => { it('should ignore duplicate provider tool-call ids already present in chat history', async () => { setupMetricsMock(); - mockGeminiClient.getHistoryToolCallFingerprints.mockReturnValue( + mockLlmClient.getHistoryToolCallFingerprints.mockReturnValue( new Map([ [ 'tool-history', @@ -3450,8 +3447,8 @@ describe('runNonInteractive', () => { ], ]), ); - const toolCallEvent: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const toolCallEvent: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-history__qwen_dup_2', providerCallId: 'tool-history', @@ -3462,13 +3459,13 @@ describe('runNonInteractive', () => { }, }; - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) .mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Final answer' }, + { type: LlmEventType.Content, value: 'Final answer' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 }, @@ -3484,11 +3481,11 @@ describe('runNonInteractive', () => { 'prompt-id-history-dup', ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(2); expect(mockCoreExecuteToolCall).not.toHaveBeenCalled(); - expect(mockGeminiClient.recordCompletedToolCall).not.toHaveBeenCalled(); + expect(mockLlmClient.recordCompletedToolCall).not.toHaveBeenCalled(); - const duplicateParts = mockGeminiClient.sendMessageStream.mock.calls[1][0]; + const duplicateParts = mockLlmClient.sendMessageStream.mock.calls[1][0]; expect(duplicateParts[0].functionResponse?.id).toBe( 'tool-history__qwen_dup_2', ); @@ -3500,7 +3497,7 @@ describe('runNonInteractive', () => { it('executes an id-colliding tool call whose args differ from the handled call', async () => { setupMetricsMock(); - mockGeminiClient.getHistoryToolCallFingerprints.mockReturnValue( + mockLlmClient.getHistoryToolCallFingerprints.mockReturnValue( new Map([ [ 'tool-history', @@ -3508,8 +3505,8 @@ describe('runNonInteractive', () => { ], ]), ); - const toolCallEvent: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const toolCallEvent: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-history__qwen_dup_2', providerCallId: 'tool-history', @@ -3535,13 +3532,13 @@ describe('runNonInteractive', () => { errorType: undefined, }); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) .mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Final answer' }, + { type: LlmEventType.Content, value: 'Final answer' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 }, @@ -3558,8 +3555,8 @@ describe('runNonInteractive', () => { ); expect(mockCoreExecuteToolCall).toHaveBeenCalledTimes(1); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); - const resultParts = mockGeminiClient.sendMessageStream.mock.calls[1][0]; + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(2); + const resultParts = mockLlmClient.sendMessageStream.mock.calls[1][0]; expect( JSON.stringify(resultParts[0].functionResponse?.response), ).not.toContain('Duplicate provider tool call id'); @@ -3583,8 +3580,8 @@ describe('runNonInteractive', () => { flush: vi.fn().mockResolvedValue(undefined), }); vi.mocked(mockConfig.getMaxToolCalls).mockReturnValue(1); - const firstToolCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const firstToolCall: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-1', providerCallId: 'tool-1', @@ -3594,8 +3591,8 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-same-batch-dup', }, }; - const duplicateToolCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const duplicateToolCall: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-1', providerCallId: 'tool-1', @@ -3635,15 +3632,15 @@ describe('runNonInteractive', () => { }, ); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents([firstToolCall, duplicateToolCall]), ) .mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Final answer' }, + { type: LlmEventType.Content, value: 'Final answer' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 }, @@ -3659,11 +3656,11 @@ describe('runNonInteractive', () => { 'prompt-id-same-batch-dup', ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(2); expect(mockCoreExecuteToolCall).toHaveBeenCalledTimes(1); - expect(mockGeminiClient.recordCompletedToolCall).toHaveBeenCalledTimes(1); + expect(mockLlmClient.recordCompletedToolCall).toHaveBeenCalledTimes(1); - const toolResultParts = mockGeminiClient.sendMessageStream.mock.calls[1][0]; + const toolResultParts = mockLlmClient.sendMessageStream.mock.calls[1][0]; expect(toolResultParts).toHaveLength(2); expect(toolResultParts[0]).toEqual({ text: 'Tool response' }); expect(toolResultParts[1].functionResponse?.response?.['error']).toContain( @@ -3682,8 +3679,8 @@ describe('runNonInteractive', () => { it('should handle error during tool execution and should send error back to the model', async () => { setupMetricsMock(); - const toolCallEvent: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const toolCallEvent: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-1', name: 'errorTool', @@ -3707,17 +3704,17 @@ describe('runNonInteractive', () => { ], resultDisplay: 'Execution failed', }); - const finalResponse: ServerGeminiStreamEvent[] = [ + const finalResponse: ServerLlmStreamEvent[] = [ { - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Sorry, let me try again.', }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, }, ]; - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) .mockReturnValueOnce(createStreamFromEvents(finalResponse)); @@ -3729,8 +3726,8 @@ describe('runNonInteractive', () => { ); expect(mockCoreExecuteToolCall).toHaveBeenCalled(); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); - expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(mockLlmClient.sendMessageStream).toHaveBeenNthCalledWith( 2, [ { @@ -3752,7 +3749,7 @@ describe('runNonInteractive', () => { it('should exit with error if sendMessageStream throws initially', async () => { setupMetricsMock(); const apiError = new Error('API connection failed: token=secret'); - mockGeminiClient.sendMessageStream.mockImplementation(() => { + mockLlmClient.sendMessageStream.mockImplementation(() => { throw apiError; }); @@ -3774,8 +3771,8 @@ describe('runNonInteractive', () => { it('should not exit if a tool is not found, and should send error back to model', async () => { setupMetricsMock(); - const toolCallEvent: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const toolCallEvent: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-1', name: 'nonexistentTool', @@ -3789,18 +3786,18 @@ describe('runNonInteractive', () => { resultDisplay: 'Tool "nonexistentTool" not found in registry.', responseParts: [], }); - const finalResponse: ServerGeminiStreamEvent[] = [ + const finalResponse: ServerLlmStreamEvent[] = [ { - type: GeminiEventType.Content, + type: LlmEventType.Content, value: "Sorry, I can't find that tool.", }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, }, ]; - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) .mockReturnValueOnce(createStreamFromEvents(finalResponse)); @@ -3812,7 +3809,7 @@ describe('runNonInteractive', () => { ); expect(mockCoreExecuteToolCall).toHaveBeenCalled(); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(2); expect(processStdoutSpy).toHaveBeenCalledWith( "Sorry, I can't find that tool.\n", ); @@ -3855,14 +3852,14 @@ describe('runNonInteractive', () => { }); // Mock a simple stream response from the Gemini client - const events: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'Summary complete.' }, + const events: ServerLlmStreamEvent[] = [ + { type: LlmEventType.Content, value: 'Summary complete.' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); @@ -3870,7 +3867,7 @@ describe('runNonInteractive', () => { await runNonInteractive(mockConfig, mockSettings, rawInput, 'prompt-id-7'); // 5. Assert that sendMessageStream was called with the PROCESSED parts, not the raw input - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledWith( processedParts, expect.any(AbortSignal), 'prompt-id-7', @@ -3896,8 +3893,8 @@ describe('runNonInteractive', () => { const selector = 'vision-agent\0https://vision.example/v1\0'; let acceptedSameSelector = false; let rejectedDifferentSelector = false; - const toolCallEvent: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const toolCallEvent: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'vision-tool-1', name: 'testTool', @@ -3920,11 +3917,11 @@ describe('runNonInteractive', () => { }; }, ); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) .mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'done' }, + { type: LlmEventType.Content, value: 'done' }, ...finishedEvents, ]), ); @@ -3941,7 +3938,7 @@ describe('runNonInteractive', () => { }); expect(acceptedSameSelector).toBe(true); expect(rejectedDifferentSelector).toBe(true); - expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenNthCalledWith( 1, headlessImageParts, expect.any(AbortSignal), @@ -3952,7 +3949,7 @@ describe('runNonInteractive', () => { submittedPrompt: 'inspect @image.png', }, ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenNthCalledWith( 2, [{ text: 'tool response' }], expect.any(AbortSignal), @@ -3986,8 +3983,8 @@ describe('runNonInteractive', () => { }); }, ); - const drainToolCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const drainToolCall: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'drain-tool-1', name: 'testTool', @@ -3999,7 +3996,7 @@ describe('runNonInteractive', () => { mockCoreExecuteToolCall.mockResolvedValue({ responseParts: [{ text: 'drain tool response' }], }); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents(finishedEvents)) .mockReturnValueOnce(createStreamFromEvents([drainToolCall])) .mockReturnValueOnce(createStreamFromEvents(finishedEvents)); @@ -4011,7 +4008,7 @@ describe('runNonInteractive', () => { 'prompt-drain-isolation', ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenNthCalledWith( 2, [{ text: 'task result' }], expect.any(AbortSignal), @@ -4021,7 +4018,7 @@ describe('runNonInteractive', () => { modelOverride: undefined, }), ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenNthCalledWith( 3, [{ text: 'drain tool response' }], expect.any(AbortSignal), @@ -4050,7 +4047,7 @@ describe('runNonInteractive', () => { modelId: 'vision-bridge', egressOccurred: true, }); - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(finishedEvents), ); @@ -4066,7 +4063,7 @@ describe('runNonInteractive', () => { parts: headlessImageParts, signal: expect.any(AbortSignal), }); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledWith( [{ text: 'machine transcription' }], expect.any(AbortSignal), 'prompt-vision-bridge', @@ -4098,7 +4095,7 @@ describe('runNonInteractive', () => { modelId: 'vision-bridge', egressOccurred: true, }); - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(finishedEvents), ); const writes: string[] = []; @@ -4136,7 +4133,7 @@ describe('runNonInteractive', () => { await mockHeadlessImageInput(); configureHeadlessVisionModel({ id: 'vision-bridge' }); runVisionBridgeSpy.mockRejectedValue(new Error('bridge unavailable')); - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(finishedEvents), ); @@ -4147,7 +4144,7 @@ describe('runNonInteractive', () => { 'prompt-vision-bridge-failed', ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledWith( [{ text: 'inspect this image' }], expect.any(AbortSignal), 'prompt-vision-bridge-failed', @@ -4174,7 +4171,7 @@ describe('runNonInteractive', () => { modelId: 'vision-bridge', egressOccurred: true, }); - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(finishedEvents), ); @@ -4185,7 +4182,7 @@ describe('runNonInteractive', () => { 'prompt-vision-bridge-skipped', ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledWith( [{ text: 'inspect this image' }], expect.any(AbortSignal), 'prompt-vision-bridge-skipped', @@ -4211,7 +4208,7 @@ describe('runNonInteractive', () => { id: 'vision-agent', agentCapable: true, }); - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(finishedEvents), ); @@ -4227,7 +4224,7 @@ describe('runNonInteractive', () => { } expect(resolveForModel).not.toHaveBeenCalled(); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledWith( [ { text: 'inspect this image' }, expect.objectContaining({ @@ -4265,18 +4262,18 @@ describe('runNonInteractive', () => { expect(resolveForModel).toHaveBeenCalledWith('vision-agent', { failClosed: true, }); - expect(mockGeminiClient.sendMessageStream).not.toHaveBeenCalled(); + expect(mockLlmClient.sendMessageStream).not.toHaveBeenCalled(); }); it('should process input and write JSON output with stats', async () => { - const events: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'Hello World' }, + const events: ServerLlmStreamEvent[] = [ + { type: LlmEventType.Content, value: 'Hello World' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); @@ -4289,7 +4286,7 @@ describe('runNonInteractive', () => { 'prompt-id-1', ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledWith( [{ text: 'Test input' }], expect.any(AbortSignal), 'prompt-id-1', @@ -4325,8 +4322,8 @@ describe('runNonInteractive', () => { it('should write JSON output with stats for tool-only commands (no text response)', async () => { // Test the scenario where a command completes successfully with only tool calls // but no text response - this would have caught the original bug - const toolCallEvent: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const toolCallEvent: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-1', name: 'testTool', @@ -4339,23 +4336,23 @@ describe('runNonInteractive', () => { mockCoreExecuteToolCall.mockResolvedValue({ responseParts: toolResponse }); // First call returns only tool call, no content - const firstCallEvents: ServerGeminiStreamEvent[] = [ + const firstCallEvents: ServerLlmStreamEvent[] = [ toolCallEvent, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, }, ]; // Second call returns no content (tool-only completion) - const secondCallEvents: ServerGeminiStreamEvent[] = [ + const secondCallEvents: ServerLlmStreamEvent[] = [ { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } }, }, ]; - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); @@ -4396,7 +4393,7 @@ describe('runNonInteractive', () => { 'prompt-id-tool-only', ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(2); expect(mockCoreExecuteToolCall).toHaveBeenCalledWith( mockConfig, expect.objectContaining({ name: 'testTool' }), @@ -4429,13 +4426,13 @@ describe('runNonInteractive', () => { it('should write JSON output with stats for empty response commands', async () => { // Test the scenario where a command completes but produces no content at all - const events: ServerGeminiStreamEvent[] = [ + const events: ServerLlmStreamEvent[] = [ { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); @@ -4448,7 +4445,7 @@ describe('runNonInteractive', () => { 'prompt-id-empty', ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledWith( [{ text: 'Empty response test' }], expect.any(AbortSignal), 'prompt-id-empty', @@ -4486,7 +4483,7 @@ describe('runNonInteractive', () => { setupMetricsMock(); const testError = new Error('Invalid input provided'); - mockGeminiClient.sendMessageStream.mockImplementation(() => { + mockLlmClient.sendMessageStream.mockImplementation(() => { throw testError; }); @@ -4526,8 +4523,8 @@ describe('runNonInteractive', () => { setupMetricsMock(); // Simulate an API error event (like 401 unauthorized) - const apiErrorEvent: ServerGeminiStreamEvent = { - type: GeminiEventType.Error, + const apiErrorEvent: ServerLlmStreamEvent = { + type: LlmEventType.Error, value: { error: { message: '401 Incorrect API key provided', @@ -4536,7 +4533,7 @@ describe('runNonInteractive', () => { }, }; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents([apiErrorEvent]), ); @@ -4591,8 +4588,8 @@ describe('runNonInteractive', () => { } ).getChatRecordingService = () => ({ finalize, flush }); - const apiErrorEvent: ServerGeminiStreamEvent = { - type: GeminiEventType.Error, + const apiErrorEvent: ServerLlmStreamEvent = { + type: LlmEventType.Error, value: { error: { message: '402 Model gpt-oss-120b is not available for billing.', @@ -4601,7 +4598,7 @@ describe('runNonInteractive', () => { }, }; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents([apiErrorEvent]), ); @@ -4648,7 +4645,7 @@ describe('runNonInteractive', () => { setupMetricsMock(); const fatalError = new FatalInputError('Invalid command syntax provided'); - mockGeminiClient.sendMessageStream.mockImplementation(() => { + mockLlmClient.sendMessageStream.mockImplementation(() => { throw fatalError; }); @@ -4696,14 +4693,14 @@ describe('runNonInteractive', () => { }; mockGetCommands.mockReturnValue([mockCommand]); - const events: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'Response from command' }, + const events: ServerLlmStreamEvent[] = [ + { type: LlmEventType.Content, value: 'Response from command' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); @@ -4715,7 +4712,7 @@ describe('runNonInteractive', () => { ); // Ensure the prompt sent to the model is from the command, not the raw input - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledWith( [{ text: 'Prompt from command' }], expect.any(AbortSignal), 'prompt-id-slash', @@ -4760,14 +4757,14 @@ describe('runNonInteractive', () => { // No commands are mocked, so any slash command is "unknown" mockGetCommands.mockReturnValue([]); - const events: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'Response to unknown' }, + const events: ServerLlmStreamEvent[] = [ + { type: LlmEventType.Content, value: 'Response to unknown' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); @@ -4779,7 +4776,7 @@ describe('runNonInteractive', () => { ); // Ensure the raw input is sent to the model - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledWith( [{ text: '/unknowncommand' }], expect.any(AbortSignal), 'prompt-id-unknown', @@ -4856,14 +4853,14 @@ describe('runNonInteractive', () => { }; mockGetCommands.mockReturnValue([mockCommand]); - const events: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'Acknowledged' }, + const events: ServerLlmStreamEvent[] = [ + { type: LlmEventType.Content, value: 'Acknowledged' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); @@ -4894,14 +4891,14 @@ describe('runNonInteractive', () => { return true; }); - const events: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'Hello stream' }, + const events: ServerLlmStreamEvent[] = [ + { type: LlmEventType.Content, value: 'Hello stream' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 4 } }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); @@ -4951,13 +4948,13 @@ describe('runNonInteractive', () => { return true; }); const turnAbortController = new AbortController(); - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( (async function* () { turnAbortController.abort(new TurnInterruptedError()); yield { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, - } as ServerGeminiStreamEvent; + } as ServerLlmStreamEvent; })(), ); @@ -5013,11 +5010,11 @@ describe('runNonInteractive', () => { }); }, ); - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Fork launched.' }, + { type: LlmEventType.Content, value: 'Fork launched.' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 2 }, @@ -5121,12 +5118,12 @@ describe('runNonInteractive', () => { ); }); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Monitor launched.' }, + { type: LlmEventType.Content, value: 'Monitor launched.' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 2 }, @@ -5136,9 +5133,9 @@ describe('runNonInteractive', () => { ) .mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Observed.' }, + { type: LlmEventType.Content, value: 'Observed.' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 }, @@ -5154,8 +5151,8 @@ describe('runNonInteractive', () => { 'prompt-monitor', ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); - expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(mockLlmClient.sendMessageStream).toHaveBeenNthCalledWith( 2, [{ text: notificationXml }], expect.any(AbortSignal), @@ -5236,12 +5233,12 @@ describe('runNonInteractive', () => { todoWorkChainId: 'chain-2', }); }); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Started.' }, + { type: LlmEventType.Content, value: 'Started.' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 }, @@ -5251,9 +5248,9 @@ describe('runNonInteractive', () => { ) .mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'First notification.' }, + { type: LlmEventType.Content, value: 'First notification.' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 }, @@ -5263,9 +5260,9 @@ describe('runNonInteractive', () => { ) .mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Second notification.' }, + { type: LlmEventType.Content, value: 'Second notification.' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 }, @@ -5281,8 +5278,8 @@ describe('runNonInteractive', () => { 'prompt-monitor-work-chains', ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(3); - expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(3); + expect(mockLlmClient.sendMessageStream).toHaveBeenNthCalledWith( 2, [{ text: firstNotificationXml }], expect.any(AbortSignal), @@ -5291,7 +5288,7 @@ describe('runNonInteractive', () => { todoWorkChainId: 'chain-1', }), ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenNthCalledWith( 3, [{ text: secondNotificationXml }], expect.any(AbortSignal), @@ -5316,11 +5313,11 @@ describe('runNonInteractive', () => { return true; }); - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Handled once' }, + { type: LlmEventType.Content, value: 'Handled once' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 2 } }, }, ]), @@ -5398,11 +5395,11 @@ describe('runNonInteractive', () => { }); monitorStatus = 'cancelled'; }); - mockGeminiClient.sendMessageStream.mockReturnValueOnce( + mockLlmClient.sendMessageStream.mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Monitor stopped.' }, + { type: LlmEventType.Content, value: 'Monitor stopped.' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 2 }, @@ -5418,7 +5415,7 @@ describe('runNonInteractive', () => { 'prompt-monitor-cancel', ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(1); const envelopes = writes .join('') .split('\n') @@ -5527,8 +5524,8 @@ describe('runNonInteractive', () => { ); }); - async function* secondTurnStream(): AsyncGenerator { - yield { type: GeminiEventType.Content, value: 'Observed.' }; + async function* secondTurnStream(): AsyncGenerator { + yield { type: LlmEventType.Content, value: 'Observed.' }; monitorNotificationCallback?.( 'Monitor "logs" event #2: still running', secondNotificationXml, @@ -5540,7 +5537,7 @@ describe('runNonInteractive', () => { }, ); yield { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 }, @@ -5548,12 +5545,12 @@ describe('runNonInteractive', () => { }; } - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Monitor launched.' }, + { type: LlmEventType.Content, value: 'Monitor launched.' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 2 }, @@ -5570,7 +5567,7 @@ describe('runNonInteractive', () => { 'prompt-monitor-cutover', ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(2); const envelopes = writes .join('') @@ -5693,12 +5690,12 @@ describe('runNonInteractive', () => { ); }); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Monitor launched.' }, + { type: LlmEventType.Content, value: 'Monitor launched.' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 2 }, @@ -5708,9 +5705,9 @@ describe('runNonInteractive', () => { ) .mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Observed.' }, + { type: LlmEventType.Content, value: 'Observed.' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 }, @@ -5799,9 +5796,9 @@ describe('runNonInteractive', () => { return current; }); - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'All done' }, + { type: LlmEventType.Content, value: 'All done' }, ]), ); @@ -5845,14 +5842,14 @@ describe('runNonInteractive', () => { return true; }); - const events: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'Response from envelope' }, + const events: ServerLlmStreamEvent[] = [ + { type: LlmEventType.Content, value: 'Response from envelope' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); @@ -5897,7 +5894,7 @@ describe('runNonInteractive', () => { expect(assistantEnvelope).toBeTruthy(); // Verify the model received the correct parts from userMessage - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledWith( [{ text: 'Message from stream-json input' }], expect.any(AbortSignal), 'prompt-envelope', @@ -5924,8 +5921,8 @@ describe('runNonInteractive', () => { return true; }); - const toolCallEvent: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const toolCallEvent: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-1', name: 'testTool', @@ -5944,16 +5941,16 @@ describe('runNonInteractive', () => { ]; mockCoreExecuteToolCall.mockResolvedValue({ responseParts: toolResponse }); - const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent]; - const secondCallEvents: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'Final response' }, + const firstCallEvents: ServerLlmStreamEvent[] = [toolCallEvent]; + const secondCallEvents: ServerLlmStreamEvent[] = [ + { type: LlmEventType.Content, value: 'Final response' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, }, ]; - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); @@ -6026,8 +6023,8 @@ describe('runNonInteractive', () => { return true; }); - const toolCallEvent: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const toolCallEvent: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-error', name: 'errorTool', @@ -6053,17 +6050,17 @@ describe('runNonInteractive', () => { resultDisplay: 'Tool execution failed', }); - const finalResponse: ServerGeminiStreamEvent[] = [ + const finalResponse: ServerLlmStreamEvent[] = [ { - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'I encountered an error', }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, }, ]; - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) .mockReturnValueOnce(createStreamFromEvents(finalResponse)); @@ -6123,15 +6120,15 @@ describe('runNonInteractive', () => { return true; }); - const events: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'Hello' }, - { type: GeminiEventType.Content, value: ' World' }, + const events: ServerLlmStreamEvent[] = [ + { type: LlmEventType.Content, value: 'Hello' }, + { type: LlmEventType.Content, value: ' World' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); @@ -6180,18 +6177,18 @@ describe('runNonInteractive', () => { return true; }); - const events: ServerGeminiStreamEvent[] = [ + const events: ServerLlmStreamEvent[] = [ { - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: { subject: 'Analysis', description: 'Processing request' }, }, - { type: GeminiEventType.Content, value: 'Response text' }, + { type: LlmEventType.Content, value: 'Response text' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 8 } }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); @@ -6238,8 +6235,8 @@ describe('runNonInteractive', () => { return true; }); - const toolCall1: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const toolCall1: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-1', name: 'firstTool', @@ -6248,8 +6245,8 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-multi', }, }; - const toolCall2: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const toolCall2: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-2', name: 'secondTool', @@ -6267,16 +6264,16 @@ describe('runNonInteractive', () => { responseParts: [{ text: 'Second tool result' }], }); - const firstCallEvents: ServerGeminiStreamEvent[] = [toolCall1, toolCall2]; - const secondCallEvents: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'Combined response' }, + const firstCallEvents: ServerLlmStreamEvent[] = [toolCall1, toolCall2]; + const secondCallEvents: ServerLlmStreamEvent[] = [ + { type: LlmEventType.Content, value: 'Combined response' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 15 } }, }, ]; - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); @@ -6346,8 +6343,8 @@ describe('runNonInteractive', () => { return true; }); - const duplicateToolCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const duplicateToolCall: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'dup_id_0001', name: 'read_file', @@ -6356,8 +6353,8 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-dup', }, }; - const replayedToolCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const replayedToolCall: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'dup_id_0001', name: 'read_file', @@ -6378,15 +6375,15 @@ describe('runNonInteractive', () => { }, ], }); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents([duplicateToolCall, replayedToolCall]), ) .mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'done' }, + { type: LlmEventType.Content, value: 'done' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 }, @@ -6413,7 +6410,7 @@ describe('runNonInteractive', () => { expect.any(Object), ); - const toolResultParts = mockGeminiClient.sendMessageStream.mock.calls[1][0]; + const toolResultParts = mockLlmClient.sendMessageStream.mock.calls[1][0]; expect(toolResultParts).toHaveLength(2); expect(toolResultParts[0].functionResponse?.response?.['output']).toBe( 'first', @@ -6448,8 +6445,8 @@ describe('runNonInteractive', () => { (mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false); setupMetricsMock(); - const firstToolCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const firstToolCall: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: '', name: 'read_file', @@ -6458,8 +6455,8 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-empty', }, }; - const secondToolCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const secondToolCall: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: '', name: 'read_file', @@ -6480,15 +6477,15 @@ describe('runNonInteractive', () => { }, ], }); - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents([firstToolCall, secondToolCall]), ) .mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'done' }, + { type: LlmEventType.Content, value: 'done' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 }, @@ -6542,14 +6539,14 @@ describe('runNonInteractive', () => { return true; }); - const events: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'Response' }, + const events: ServerLlmStreamEvent[] = [ + { type: LlmEventType.Content, value: 'Response' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); @@ -6575,7 +6572,7 @@ describe('runNonInteractive', () => { }, ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledWith( [{ text: 'Simple string content' }], expect.any(AbortSignal), 'prompt-string-content', @@ -6587,7 +6584,7 @@ describe('runNonInteractive', () => { ); // UserMessage with array of text blocks - mockGeminiClient.sendMessageStream.mockClear(); + mockLlmClient.sendMessageStream.mockClear(); const userMessageBlocks: CLIUserMessage = { type: 'user', uuid: 'test-uuid-2', @@ -6612,7 +6609,7 @@ describe('runNonInteractive', () => { }, ); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledWith( [{ text: 'First part' }, { text: 'Second part' }], expect.any(AbortSignal), 'prompt-blocks-content', @@ -6641,11 +6638,11 @@ describe('runNonInteractive', () => { const skipSpy = vi.spyOn(scheduler, 'setSkipDurableFire'); mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'ok' }, + { type: LlmEventType.Content, value: 'ok' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, }, ]), @@ -6720,8 +6717,8 @@ describe('runNonInteractive', () => { // (hypothetical side-effecting) tool. The break must prevent the // second tool from running. const structuredArgs = { summary: 'done' }; - const structuredCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const structuredCall: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-structured', name: 'structured_output', @@ -6730,8 +6727,8 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-structured', }, }; - const trailingCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const trailingCall: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-trailing', name: 'side_effect_tool', @@ -6745,7 +6742,7 @@ describe('runNonInteractive', () => { responseParts: [{ text: 'ok' }], }); - mockGeminiClient.sendMessageStream.mockReturnValueOnce( + mockLlmClient.sendMessageStream.mockReturnValueOnce( createStreamFromEvents([structuredCall, trailingCall]), ); @@ -6765,7 +6762,7 @@ describe('runNonInteractive', () => { expect(firstCallArg.name).toBe('structured_output'); // And we should not have sent a second follow-up turn. - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(1); // abortAll() must be called so any in-flight background agents are // torn down before we emit the terminal result. @@ -6850,8 +6847,8 @@ describe('runNonInteractive', () => { // having already executed write_file would violate the "structured // output is the terminal contract" guarantee. const structuredArgs = { summary: 'done' }; - const leadingCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const leadingCall: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-structured', name: 'side_effect_tool', @@ -6860,8 +6857,8 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-leading', }, }; - const structuredCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const structuredCall: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-structured', name: 'structured_output', @@ -6875,7 +6872,7 @@ describe('runNonInteractive', () => { responseParts: [{ text: 'ok' }], }); - mockGeminiClient.sendMessageStream.mockReturnValueOnce( + mockLlmClient.sendMessageStream.mockReturnValueOnce( createStreamFromEvents([leadingCall, structuredCall]), ); @@ -6894,7 +6891,7 @@ describe('runNonInteractive', () => { }; expect(onlyCallArg.name).toBe('structured_output'); // No follow-up turn should have been issued. - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(1); expect(abortAllSpy).toHaveBeenCalledTimes(1); const events = writes @@ -6977,8 +6974,8 @@ describe('runNonInteractive', () => { }); const goodArgs = { summary: 'ok' }; - const badStructured: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const badStructured: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-structured-bad', name: 'structured_output', @@ -6987,8 +6984,8 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-multi-struct', }, }; - const goodStructured: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const goodStructured: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-structured-good', name: 'structured_output', @@ -6998,7 +6995,7 @@ describe('runNonInteractive', () => { }, }; - mockGeminiClient.sendMessageStream.mockReturnValueOnce( + mockLlmClient.sendMessageStream.mockReturnValueOnce( createStreamFromEvents([badStructured, goodStructured]), ); @@ -7044,7 +7041,7 @@ describe('runNonInteractive', () => { ]); // No retry turn was needed. - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(1); expect(abortAllSpy).toHaveBeenCalledTimes(1); // Result must reflect the second (successful) structured_output's @@ -7079,8 +7076,8 @@ describe('runNonInteractive', () => { // tool returns a tool-execution error). The session must NOT terminate // — `!toolResponse.error` keeps `structuredSubmission` undefined and // we feed the validation failure back so the model can retry. - const invalidStructured: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const invalidStructured: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-structured-invalid', name: 'structured_output', @@ -7090,8 +7087,8 @@ describe('runNonInteractive', () => { }, }; // Second turn: model retries with valid args. - const validStructured: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const validStructured: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-structured-valid', name: 'structured_output', @@ -7101,7 +7098,7 @@ describe('runNonInteractive', () => { }, }; - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents([invalidStructured])) .mockReturnValueOnce(createStreamFromEvents([validStructured])); @@ -7139,7 +7136,7 @@ describe('runNonInteractive', () => { // A second sendMessageStream call confirms the retry turn was issued // — the failed first attempt did not short-circuit the run. - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(2); }); it('errors with non-zero exit when model emits plain text instead of structured_output', async () => { @@ -7160,14 +7157,14 @@ describe('runNonInteractive', () => { return true; }); - const plainTextTurn: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'Here is my answer as text.' }, + const plainTextTurn: ServerLlmStreamEvent[] = [ + { type: LlmEventType.Content, value: 'Here is my answer as text.' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValueOnce( + mockLlmClient.sendMessageStream.mockReturnValueOnce( createStreamFromEvents(plainTextTurn), ); @@ -7215,8 +7212,8 @@ describe('runNonInteractive', () => { (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); setupMetricsMock(); - const leadingCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const leadingCall: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-leading', name: 'side_effect_tool', @@ -7225,8 +7222,8 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-suppress-pair', }, }; - const badStructuredCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const badStructuredCall: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-structured-bad', name: 'structured_output', @@ -7235,8 +7232,8 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-suppress-pair', }, }; - const goodStructuredCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const goodStructuredCall: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-structured-good', name: 'structured_output', @@ -7246,7 +7243,7 @@ describe('runNonInteractive', () => { }, }; - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents([leadingCall, badStructuredCall]), ) @@ -7288,8 +7285,8 @@ describe('runNonInteractive', () => { // The retry message sent to the model must contain BOTH a tool_result // for the suppressed side_effect_tool and one for the failed // structured_output, so every prior tool_use is paired. - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); - const retryParts = mockGeminiClient.sendMessageStream.mock.calls[1][0] as + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(2); + const retryParts = mockLlmClient.sendMessageStream.mock.calls[1][0] as | Array<{ functionResponse?: { id?: string; name?: string }; }> @@ -7349,8 +7346,8 @@ describe('runNonInteractive', () => { (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); setupMetricsMock(); - const firstSideEffectCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const firstSideEffectCall: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-side', providerCallId: 'tool-side', @@ -7360,8 +7357,8 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-dup-structured', }, }; - const duplicateSideEffectCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const duplicateSideEffectCall: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-side', providerCallId: 'tool-side', @@ -7374,8 +7371,8 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-dup-structured', }, }; - const badStructuredCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const badStructuredCall: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-structured-bad', name: 'structured_output', @@ -7384,8 +7381,8 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-dup-structured', }, }; - const goodStructuredCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const goodStructuredCall: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-structured-good', name: 'structured_output', @@ -7395,7 +7392,7 @@ describe('runNonInteractive', () => { }, }; - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents([firstSideEffectCall])) .mockReturnValueOnce( createStreamFromEvents([duplicateSideEffectCall, badStructuredCall]), @@ -7447,8 +7444,8 @@ describe('runNonInteractive', () => { 'structured_output', ]); - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(3); - const retryParts = mockGeminiClient.sendMessageStream.mock.calls[2][0] as + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(3); + const retryParts = mockLlmClient.sendMessageStream.mock.calls[2][0] as | Array<{ functionResponse?: { id?: string; @@ -7533,8 +7530,8 @@ describe('runNonInteractive', () => { }); const drainStructuredArgs = { summary: 'drain-captured' }; - const drainStructuredCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const drainStructuredCall: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-drain-structured', name: 'structured_output', @@ -7547,12 +7544,12 @@ describe('runNonInteractive', () => { // First turn: plain text, no tool calls — drains into the queue. // Drain turn: model invokes structured_output as the reply to the // notification. - mockGeminiClient.sendMessageStream + mockLlmClient.sendMessageStream .mockReturnValueOnce( createStreamFromEvents([ - { type: GeminiEventType.Content, value: 'Monitor launched.' }, + { type: LlmEventType.Content, value: 'Monitor launched.' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 2 }, @@ -7579,7 +7576,7 @@ describe('runNonInteractive', () => { // Two stream calls: main + drain reply. structured_output executed // exactly once (during drain). - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(2); expect(mockCoreExecuteToolCall).toHaveBeenCalledTimes(1); const drainCallArg = mockCoreExecuteToolCall.mock.calls[0][1] as { name: string; @@ -7685,8 +7682,8 @@ describe('runNonInteractive', () => { }); const structuredArgs = { summary: 'done' }; - const structuredCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const structuredCall: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-structured', name: 'structured_output', @@ -7698,7 +7695,7 @@ describe('runNonInteractive', () => { mockCoreExecuteToolCall.mockResolvedValue({ responseParts: [{ text: 'ok' }], }); - mockGeminiClient.sendMessageStream.mockReturnValueOnce( + mockLlmClient.sendMessageStream.mockReturnValueOnce( createStreamFromEvents([structuredCall]), ); @@ -7781,8 +7778,8 @@ describe('runNonInteractive', () => { }); const structuredArgs = { summary: 'text-mode-ok' }; - const structuredCall: ServerGeminiStreamEvent = { - type: GeminiEventType.ToolCallRequest, + const structuredCall: ServerLlmStreamEvent = { + type: LlmEventType.ToolCallRequest, value: { callId: 'tool-structured-text', name: 'structured_output', @@ -7794,7 +7791,7 @@ describe('runNonInteractive', () => { mockCoreExecuteToolCall.mockResolvedValue({ responseParts: [{ text: 'ok' }], }); - mockGeminiClient.sendMessageStream.mockReturnValueOnce( + mockLlmClient.sendMessageStream.mockReturnValueOnce( createStreamFromEvents([structuredCall]), ); @@ -7865,14 +7862,14 @@ describe('runNonInteractive', () => { vi.fn().mockReturnValue(sessionService); setupMetricsMock(); - const events: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'ok' }, + const events: ServerLlmStreamEvent[] = [ + { type: LlmEventType.Content, value: 'ok' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); @@ -7886,7 +7883,7 @@ describe('runNonInteractive', () => { // The user message sent to the model should now begin with a // block carrying the restore notice. - const [parts] = mockGeminiClient.sendMessageStream.mock.calls[0] as [ + const [parts] = mockLlmClient.sendMessageStream.mock.calls[0] as [ Array<{ text?: string }>, ]; expect(parts.length).toBeGreaterThanOrEqual(2); @@ -7906,14 +7903,14 @@ describe('runNonInteractive', () => { (mockConfig.getResumedSessionData as Mock).mockReturnValue(undefined); setupMetricsMock(); - const events: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'ok' }, + const events: ServerLlmStreamEvent[] = [ + { type: LlmEventType.Content, value: 'ok' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); @@ -7924,7 +7921,7 @@ describe('runNonInteractive', () => { 'prompt-id-no-resume', ); - const [parts] = mockGeminiClient.sendMessageStream.mock.calls[0] as [ + const [parts] = mockLlmClient.sendMessageStream.mock.calls[0] as [ Array<{ text?: string }>, ]; // Exactly one part — the user prompt, no reminder prefix. @@ -7961,14 +7958,14 @@ describe('runNonInteractive', () => { vi.fn().mockReturnValue(sessionService); setupMetricsMock(); - const events: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'ok' }, + const events: ServerLlmStreamEvent[] = [ + { type: LlmEventType.Content, value: 'ok' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, }, ]; - mockGeminiClient.sendMessageStream.mockReturnValue( + mockLlmClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); @@ -7985,7 +7982,7 @@ describe('runNonInteractive', () => { code: 'ENOENT', }); // No injected — the user prompt is the only part. - const [parts] = mockGeminiClient.sendMessageStream.mock.calls[0] as [ + const [parts] = mockLlmClient.sendMessageStream.mock.calls[0] as [ Array<{ text?: string }>, ]; expect(parts.length).toBe(1); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 9e91837b2a2..58e33a1ed24 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -26,7 +26,7 @@ import { executeToolCall, shutdownTelemetry, isTelemetrySdkInitialized, - GeminiEventType, + LlmEventType, FatalInputError, promptIdContext, OutputFormat, @@ -386,7 +386,7 @@ async function emitNonInteractiveFinalMessage(params: { // (systemMessage should already be emitted by caller) adapter.startAssistantMessage(); adapter.processEvent({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: message, } as unknown as Parameters[0]); adapter.finalizeAssistantMessage(); @@ -582,18 +582,18 @@ export async function runNonInteractive( }); }; - const geminiClient = config.getGeminiClient(); + const llmClient = config.getLlmClient(); const abortController = options.abortController ?? new AbortController(); const queuedGoalTurns: HeadlessGoalTurn[] = []; let activeGoalTurn: HeadlessGoalTurn | undefined; let goalRuntimeUnsubscribe: (() => void) | undefined; const emitGoalSnapshot = (snapshot: GoalSnapshotV2) => { adapter.processEvent({ - type: GeminiEventType.GoalState, + type: LlmEventType.GoalState, value: snapshot, }); adapter.processEvent({ - type: GeminiEventType.ActiveGoal, + type: LlmEventType.ActiveGoal, value: projectLegacyActiveGoal(snapshot), }); }; @@ -1072,7 +1072,7 @@ export async function runNonInteractive( // runs once per (rare) continue request, so the full clone is fine. const recoveryPlan = buildSessionRecoveryPlanFromApiHistory({ sessionId, - apiHistory: geminiClient.getChat().getHistory(), + apiHistory: llmClient.getChat().getHistory(), }); debugLogger.info('[runNonInteractive] continueInterrupted recovery', { kind: recoveryPlan.kind, @@ -1268,7 +1268,7 @@ export async function runNonInteractive( } // Inject a worktree context notice into the model's first prompt. - // Two sources: the `--worktree` startup flag (set by gemini.tsx + // Two sources: the `--worktree` startup flag (set by llm.tsx // before loadCliConfig) takes precedence over the Phase C resume // restore. TUI does this via historyManager.addItem(INFO); here in // headless we prepend a `` block since there is @@ -1531,7 +1531,7 @@ export async function runNonInteractive( // An explicit inline `/model ` override wins for the whole // turn: while active, skill-tool `modelOverride` writes (including the // undefined-clears case) are skipped so they cannot silently revert the - // submitted prompt to the session model mid-turn. Unlike useGeminiStream's + // submitted prompt to the session model mid-turn. Unlike useLlmStream's // ref-based `applyModelOverride`/`clearModelOverride` helpers, this is a // run-scoped const — non-interactive mode is single-turn, so there is no // retry-clearing or skill-tool takeover to guard against, just the @@ -1686,7 +1686,7 @@ export async function runNonInteractive( // Fresh map per call today; copy so a future cached accessor cannot // turn this run's cross-turn recording into shared-state mutation. const handledToolCallFingerprints = new Map( - geminiClient.getHistoryToolCallFingerprints(), + llmClient.getHistoryToolCallFingerprints(), ); // Tracks duplicate-error responses emitted during this headless run. // Once a provider id reaches this set, seeing it again is terminal for @@ -1987,7 +1987,7 @@ export async function runNonInteractive( responseByRequest.set(requestInfo, toolResponse); terminateTurn ||= toolResponse.terminateTurn === true; config - .getGeminiClient() + .getLlmClient() .recordCompletedToolCall( requestInfo.name, requestInfo.args as Record, @@ -2332,7 +2332,7 @@ export async function runNonInteractive( const toolCallRequests: ToolCallRequestInfo[] = []; const apiStartTime = Date.now(); - const responseStream = geminiClient.sendMessageStream( + const responseStream = llmClient.sendMessageStream( currentMessages[0]?.parts || [], abortController.signal, currentPromptId, @@ -2375,21 +2375,21 @@ export async function runNonInteractive( } // Use adapter for all event processing adapter.processEvent(event); - if (event.type === GeminiEventType.ToolCallRequest) { + if (event.type === LlmEventType.ToolCallRequest) { toolCallRequests.push(event.value); } - if (event.type === GeminiEventType.ModelFallback) { + if (event.type === LlmEventType.ModelFallback) { toolCallRequests.length = 0; } if ( - event.type === GeminiEventType.Content && + event.type === LlmEventType.Content && plainTextPreview.length < PLAIN_TEXT_PREVIEW_LIMIT ) { const remaining = PLAIN_TEXT_PREVIEW_LIMIT - plainTextPreview.length; plainTextPreview += String(event.value).slice(0, remaining); } - if (event.type === GeminiEventType.LoopDetected) { + if (event.type === LlmEventType.LoopDetected) { if (!loopDetected) { loopDetectedMessage = emitLoopDetectedMessage( config, @@ -2400,7 +2400,7 @@ export async function runNonInteractive( } if ( outputFormat === OutputFormat.TEXT && - event.type === GeminiEventType.Error + event.type === LlmEventType.Error ) { const errorText = parseAndFormatApiError( event.value.error, @@ -2476,7 +2476,7 @@ export async function runNonInteractive( return emitLoopDetectedResult(); } if (terminateTurn && activeGoalTurn) { - geminiClient.addHistory({ + llmClient.addHistory({ role: 'user', parts: toolResponseParts, }); @@ -2658,7 +2658,7 @@ export async function runNonInteractive( const itemToolCallRequests: ToolCallRequestInfo[] = []; const itemApiStartTime = Date.now(); selectActiveInteraction(itemPromptId, itemIsFirstTurn); - const itemStream = geminiClient.sendMessageStream( + const itemStream = llmClient.sendMessageStream( itemMessages[0]?.parts || [], abortController.signal, itemPromptId, @@ -2700,10 +2700,10 @@ export async function runNonInteractive( await routeAbort(); } adapter.processEvent(event); - if (event.type === GeminiEventType.ToolCallRequest) { + if (event.type === LlmEventType.ToolCallRequest) { itemToolCallRequests.push(event.value); } - if (event.type === GeminiEventType.LoopDetected) { + if (event.type === LlmEventType.LoopDetected) { if (!loopDetected) { loopDetectedMessage = emitLoopDetectedMessage( config, @@ -2714,7 +2714,7 @@ export async function runNonInteractive( } if ( outputFormat === OutputFormat.TEXT && - event.type === GeminiEventType.Error + event.type === LlmEventType.Error ) { const errorText = parseAndFormatApiError( event.value.error, @@ -2954,7 +2954,7 @@ export async function runNonInteractive( } const memoryTaskPromises = config - .getGeminiClient() + .getLlmClient() .consumePendingMemoryTaskPromises(); if (memoryTaskPromises.length > 0) { await Promise.allSettled(memoryTaskPromises); diff --git a/packages/cli/src/remoteInput/RemoteInputWatcher.ts b/packages/cli/src/remoteInput/RemoteInputWatcher.ts index 23a29930243..144ab7af3f1 100644 --- a/packages/cli/src/remoteInput/RemoteInputWatcher.ts +++ b/packages/cli/src/remoteInput/RemoteInputWatcher.ts @@ -74,7 +74,7 @@ export class RemoteInputWatcher { /** * Register the TUI's submit function. Called from AppContainer - * once useGeminiStream's submitQuery is available. + * once useLlmStream's submitQuery is available. */ setSubmitFn(fn: SubmitFn): void { this.submitFn = fn; diff --git a/packages/cli/src/serve/fast-path.test.ts b/packages/cli/src/serve/fast-path.test.ts index 4e9849eef36..693432403ad 100644 --- a/packages/cli/src/serve/fast-path.test.ts +++ b/packages/cli/src/serve/fast-path.test.ts @@ -396,8 +396,8 @@ describe('CLI entry import boundary', () => { it('does not statically import the full gemini entry before the serve fast path can run', () => { const cliSource = readFileSync('src/cli.ts', 'utf8'); - expect(cliSource).not.toContain("import './gemini.js'"); - expect(cliSource).not.toContain("import { main } from './gemini.js'"); + expect(cliSource).not.toContain("import './llm.js'"); + expect(cliSource).not.toContain("import { main } from './llm.js'"); expect(cliSource).not.toContain("process.argv[2] === 'serve'"); expect(cliSource).toContain("await import('./serve/fast-path.js')"); }); diff --git a/packages/cli/src/startup/worktreeStartup.ts b/packages/cli/src/startup/worktreeStartup.ts index cd40e0d3d56..f9c1198728c 100644 --- a/packages/cli/src/startup/worktreeStartup.ts +++ b/packages/cli/src/startup/worktreeStartup.ts @@ -441,7 +441,7 @@ export async function persistStartupWorktreeSidecar( * the first user prompt (TUI: INFO history item + reminder prefix; headless: * `` prefix + JSON event; ACP currently exits before * reaching this code path — see the `--worktree` × `--acp` mutex check - * in `gemini.tsx`). + * in `llm.tsx`). * * Mirrors `restoreWorktreeContext`'s contextMessage shape so resumed-with- * worktree and started-with-worktree sessions read identically to the model. diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 168e5ba931d..301376cc7c0 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -68,7 +68,7 @@ import { type Config, makeFakeConfig, SendMessageType, - type GeminiClient, + type LlmClient, type GoalTurnHost, type SubagentManager, } from '@qwen-code/qwen-code-core'; @@ -143,7 +143,7 @@ vi.mock('./hooks/slashCommandProcessor.js'); vi.mock('./hooks/useTerminalSize.js', () => ({ useTerminalSize: vi.fn(() => ({ columns: 80, rows: 24 })), })); -vi.mock('./hooks/useGeminiStream.js'); +vi.mock('./hooks/use-llm-stream.js'); vi.mock('./hooks/vim.js'); vi.mock('./hooks/useFocus.js'); vi.mock('./hooks/useBracketedPaste.js'); @@ -203,7 +203,7 @@ import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useModelCommand } from './hooks/useModelCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; -import { useGeminiStream } from './hooks/useGeminiStream.js'; +import { useLlmStream } from './hooks/use-llm-stream.js'; import { useVim } from './hooks/vim.js'; import { useFolderTrust } from './hooks/useFolderTrust.js'; import { useIdeTrustListener } from './hooks/useIdeTrustListener.js'; @@ -239,7 +239,7 @@ describe('AppContainer State Management', () => { const mockedUseSettingsCommand = useSettingsCommand as Mock; const mockedUseModelCommand = useModelCommand as Mock; const mockedUseSlashCommandProcessor = useSlashCommandProcessor as Mock; - const mockedUseGeminiStream = useGeminiStream as Mock; + const mockedUseLlmStream = useLlmStream as Mock; const mockedUseVim = useVim as Mock; const mockedUseFolderTrust = useFolderTrust as Mock; const mockedUseIdeTrustListener = useIdeTrustListener as Mock; @@ -372,7 +372,7 @@ describe('AppContainer State Management', () => { shellConfirmationRequest: null, confirmationRequest: null, }); - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'idle', submitQuery: vi.fn(), initError: null, @@ -446,14 +446,14 @@ describe('AppContainer State Management', () => { // Mock config's getTargetDir to return consistent workspace directory vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace'); - // Mock GeminiClient to prevent unhandled errors from AgentTool.refreshSubagents - const mockGeminiClient: Partial = { + // Mock LlmClient to prevent unhandled errors from AgentTool.refreshSubagents + const mockLlmClient: Partial = { initialize: vi.fn().mockResolvedValue(undefined), setTools: vi.fn().mockResolvedValue(undefined), isInitialized: vi.fn().mockReturnValue(false), // Return false to prevent setTools from being called }; - vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue( - mockGeminiClient as GeminiClient, + vi.spyOn(mockConfig, 'getLlmClient').mockReturnValue( + mockLlmClient as LlmClient, ); // Mock SubagentManager to prevent errors during AgentTool initialization @@ -553,7 +553,7 @@ describe('AppContainer State Management', () => { filesFailed: string[]; }; fileRewindError?: Error; - noGeminiClient?: boolean; + noLlmClient?: boolean; history?: HistoryItem[]; contextFilePaths?: string[]; }; @@ -603,15 +603,15 @@ describe('AppContainer State Management', () => { ]; const getHistoryShallow = vi.fn(() => apiHistory); const truncateHistory = vi.fn(); - const geminiClient = { + const llmClient = { initialize: vi.fn().mockResolvedValue(undefined), setTools: vi.fn().mockResolvedValue(undefined), isInitialized: vi.fn().mockReturnValue(false), getHistoryShallow, truncateHistory, - } as unknown as GeminiClient; - vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue( - options.noGeminiClient ? (null as unknown as GeminiClient) : geminiClient, + } as unknown as LlmClient; + vi.spyOn(mockConfig, 'getLlmClient').mockReturnValue( + options.noLlmClient ? (null as unknown as LlmClient) : llmClient, ); const rewind = vi.fn(); @@ -727,7 +727,7 @@ describe('AppContainer State Management', () => { throw new Error('cancel failed'); }); const requestShutdown = vi.fn(); - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: StreamingState.Responding, submitQuery: vi.fn(), initError: null, @@ -738,12 +738,12 @@ describe('AppContainer State Management', () => { streamingResponseLengthRef: { current: 0 }, isReceivingContent: false, }); - vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue({ + vi.spyOn(mockConfig, 'getLlmClient').mockReturnValue({ initialize: vi.fn().mockResolvedValue(undefined), setTools: vi.fn().mockResolvedValue(undefined), isInitialized: vi.fn().mockReturnValue(false), requestShutdown, - } as unknown as GeminiClient); + } as unknown as LlmClient); render( { shellConfirmationRequest: null, confirmationRequest: null, }); - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'responding', submitQuery, initError: null, @@ -1513,7 +1513,7 @@ describe('AppContainer State Management', () => { restoreMessages: vi.fn(), drainQueue: vi.fn().mockReturnValue([]), }); - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'idle', submitQuery, initError: null, @@ -1714,7 +1714,7 @@ describe('AppContainer State Management', () => { | undefined; metadata?.onAdmissionFailed?.(); throw new Error('persistent prepare failure'); - }) as unknown as ReturnType['submitQuery']; + }) as unknown as ReturnType['submitQuery']; const { rerender } = renderHook( ({ pendingSubmissionCount, submissionSettledRevision }) => useQueuedSubmissionDrain({ @@ -1818,7 +1818,7 @@ describe('AppContainer State Management', () => { const mockQueueMessage = vi.fn(); const mockSubmitQuery = vi.fn(); - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'responding', submitQuery: mockSubmitQuery, initError: null, @@ -1866,7 +1866,7 @@ describe('AppContainer State Management', () => { const mockSubmitQuery = vi.fn(); const mockQueueMessage = vi.fn(); - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'responding', submitQuery: mockSubmitQuery, initError: null, @@ -1917,7 +1917,7 @@ describe('AppContainer State Management', () => { const mockSubmitQuery = vi.fn(); const mockQueueMessage = vi.fn(); - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'responding', submitQuery: mockSubmitQuery, initError: null, @@ -2027,7 +2027,7 @@ describe('AppContainer State Management', () => { const mockSubmitQuery = vi.fn(); const mockQueueMessage = vi.fn(); - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'idle', submitQuery: mockSubmitQuery, initError: null, @@ -2076,7 +2076,7 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'consumePendingRecoveredAgentsNotice') .mockReturnValueOnce('Use list_agents to inspect restored agents.') .mockReturnValue(null); - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'idle', submitQuery: vi.fn(), initError: null, @@ -2695,7 +2695,7 @@ describe('AppContainer State Management', () => { shellConfirmationRequest: null, confirmationRequest: null, }); - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: StreamingState.Responding, submitQuery: vi.fn(), initError: null, @@ -2735,8 +2735,8 @@ describe('AppContainer State Management', () => { }); describe('Cancel Handler (issue #3204)', () => { - // The cancel handler is wired through useGeminiStream's onCancelSubmit - // arg (positional index 15 — see the useGeminiStream call site in + // The cancel handler is wired through useLlmStream's onCancelSubmit + // arg (positional index 15 — see the useLlmStream call site in // AppContainer.tsx). We capture it via mockImplementation so a future // signature change surfaces as a clear test failure rather than silently // grabbing the wrong callback. @@ -2785,7 +2785,7 @@ describe('AppContainer State Management', () => { streamReturnValue: Record, ) => { capturedOnCancelSubmit = null; - mockedUseGeminiStream.mockImplementation((...args: unknown[]) => { + mockedUseLlmStream.mockImplementation((...args: unknown[]) => { const candidate = args[ON_CANCEL_SUBMIT_ARG_INDEX]; if (typeof candidate === 'function') { capturedOnCancelSubmit = candidate as CapturedCancelSubmit; @@ -2801,7 +2801,7 @@ describe('AppContainer State Management', () => { const triggerCancel = (info?: Parameters[0]) => { if (!capturedOnCancelSubmit) { throw new Error( - `onCancelSubmit was not captured at arg index ${ON_CANCEL_SUBMIT_ARG_INDEX} — useGeminiStream signature may have changed`, + `onCancelSubmit was not captured at arg index ${ON_CANCEL_SUBMIT_ARG_INDEX} — useLlmStream signature may have changed`, ); } capturedOnCancelSubmit(info); @@ -3117,15 +3117,15 @@ describe('AppContainer State Management', () => { getPreviousUserMessages: vi.fn().mockResolvedValue([]), removeLastUserMessage: mockRemoveLastUserMessage, }); - // Extend the default GeminiClient mock with the orphan-strip + // Extend the default LlmClient mock with the orphan-strip // entry-point so the auto-restore branch's third cleanup leg can // be observed. - vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue({ + vi.spyOn(mockConfig, 'getLlmClient').mockReturnValue({ initialize: vi.fn().mockResolvedValue(undefined), setTools: vi.fn().mockResolvedValue(undefined), isInitialized: vi.fn().mockReturnValue(false), stripOrphanedUserEntriesFromHistory: mockStripOrphans, - } as unknown as GeminiClient); + } as unknown as LlmClient); installCancelCapture({ streamingState: 'responding', submitQuery: vi.fn(), @@ -3202,12 +3202,12 @@ describe('AppContainer State Management', () => { getPreviousUserMessages: vi.fn().mockResolvedValue([]), removeLastUserMessage: mockRemoveLastUserMessage, }); - vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue({ + vi.spyOn(mockConfig, 'getLlmClient').mockReturnValue({ initialize: vi.fn().mockResolvedValue(undefined), setTools: vi.fn().mockResolvedValue(undefined), isInitialized: vi.fn().mockReturnValue(false), stripOrphanedUserEntriesFromHistory: mockStripOrphans, - } as unknown as GeminiClient); + } as unknown as LlmClient); installCancelCapture({ streamingState: 'responding', submitQuery: vi.fn(), @@ -3270,12 +3270,12 @@ describe('AppContainer State Management', () => { getPreviousUserMessages: vi.fn().mockResolvedValue([]), removeLastUserMessage: vi.fn().mockResolvedValue(true), }); - vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue({ + vi.spyOn(mockConfig, 'getLlmClient').mockReturnValue({ initialize: vi.fn().mockResolvedValue(undefined), setTools: vi.fn().mockResolvedValue(undefined), isInitialized: vi.fn().mockReturnValue(false), stripOrphanedUserEntriesFromHistory: mockStripOrphans, - } as unknown as GeminiClient); + } as unknown as LlmClient); installCancelCapture({ streamingState: 'responding', submitQuery: vi.fn(), @@ -3396,7 +3396,7 @@ describe('AppContainer State Management', () => { it('does not auto-restore when the cancelled turn did not add a user item (e.g. Cron / slash submit_prompt)', async () => { // Some submit paths (SendMessageType.Cron, slash submit_prompt) run - // through useGeminiStream without pushing a `user` history item. + // through useLlmStream without pushing a `user` history item. // If history happens to end with an older user prompt followed only // by synthetic items (e.g. info), the auto-restore guard must NOT // wrongly truncate/restore that older prompt on behalf of the @@ -3600,7 +3600,7 @@ describe('AppContainer State Management', () => { it('does not auto-restore when the sync pendingItem snapshot has meaningful content (closes stale-state race)', async () => { // Race scenario from PR review: stream chunk arrives → cancelOngoingRequest // commits via addItem → fires onCancelSubmit before React re-renders, so - // the consumer's pendingGeminiHistoryItems prop reads as [] even though + // the consumer's pendingLlmHistoryItems prop reads as [] even though // pendingHistoryItemRef.current was non-null. The synchronous snapshot // passed via info.pendingItem must override the stale React-state copy. const mockSetText = vi.fn(); @@ -4458,7 +4458,7 @@ describe('AppContainer State Management', () => { // Mock the streaming state and thought const thoughtSubject = 'Processing request'; - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'responding', submitQuery: vi.fn(), initError: null, @@ -4506,7 +4506,7 @@ describe('AppContainer State Management', () => { } as unknown as LoadedSettings; // Mock the streaming state as Idle with no thought - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'idle', submitQuery: vi.fn(), initError: null, @@ -4553,7 +4553,7 @@ describe('AppContainer State Management', () => { // Mock the streaming state and thought const thoughtSubject = 'Confirm tool execution'; - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: StreamingState.WaitingForConfirmation, submitQuery: vi.fn(), initError: null, @@ -4602,7 +4602,7 @@ describe('AppContainer State Management', () => { // Mock the streaming state and thought with a short subject const shortTitle = 'Short'; - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'responding', submitQuery: vi.fn(), initError: null, @@ -4656,7 +4656,7 @@ describe('AppContainer State Management', () => { // Mock the streaming state and thought const title = 'Test Title'; - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'responding', submitQuery: vi.fn(), initError: null, @@ -4707,7 +4707,7 @@ describe('AppContainer State Management', () => { vi.stubEnv('CLI_TITLE', 'Custom Title'); // Mock the streaming state as Idle with no thought - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'idle', submitQuery: vi.fn(), initError: null, @@ -4775,7 +4775,7 @@ describe('AppContainer State Management', () => { ReturnType >); - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'idle', submitQuery: vi.fn(), initError: null, @@ -4875,7 +4875,7 @@ describe('AppContainer State Management', () => { ReturnType >); - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'idle', submitQuery: vi.fn(), initError: null, @@ -5003,7 +5003,7 @@ describe('AppContainer State Management', () => { mockedUseTerminalSize.mockReturnValue({ columns: 80, rows: 5 }); mockedMeasureElement.mockReturnValue({ width: 80, height: 10 }); // Footer is taller than the screen - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'idle', submitQuery: vi.fn(), initError: null, @@ -5334,7 +5334,7 @@ describe('AppContainer State Management', () => { it('should cancel ongoing request on first Ctrl+C', () => { const mockCancelOngoingRequest = vi.fn(); - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'responding', submitQuery: vi.fn(), initError: null, @@ -5412,7 +5412,7 @@ describe('AppContainer State Management', () => { request: { callId: 'call-shell-1', name: 'run_shell_command' }, promoteAbortController: promoteAc, }; - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'responding', submitQuery: vi.fn(), initError: null, @@ -5469,7 +5469,7 @@ describe('AppContainer State Management', () => { const promoteAc2 = new AbortController(); const abortSpy1 = vi.spyOn(promoteAc1, 'abort'); const abortSpy2 = vi.spyOn(promoteAc2, 'abort'); - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'responding', submitQuery: vi.fn(), initError: null, @@ -5529,7 +5529,7 @@ describe('AppContainer State Management', () => { // Pin the safety contract: pressing Ctrl+B mid-prompt with no // pending tool calls must NOT throw — falls through to the input // layer's own Ctrl+B (cursor-left). - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'responding', submitQuery: vi.fn(), initError: null, @@ -5590,7 +5590,7 @@ describe('AppContainer State Management', () => { // be filtered out by the tool-name guard. promoteAbortController: fakeNonShellAc, }; - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'responding', submitQuery: vi.fn(), initError: null, @@ -5653,7 +5653,7 @@ describe('AppContainer State Management', () => { const ctrlO = makeKey({ name: 'o', ctrl: true, sequence: '\x0f' }); it('Ctrl+O flips the full-detail state that expands thoughts and tool output', () => { - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'idle', submitQuery: vi.fn(), initError: null, @@ -6150,7 +6150,7 @@ describe('AppContainer State Management', () => { }); it('shows an error and returns for conversation-only rewind with no client', async () => { - const harness = renderRewindHarness({ noGeminiClient: true }); + const harness = renderRewindHarness({ noLlmClient: true }); await runRewind(harness.target, 'conversation'); @@ -6167,7 +6167,7 @@ describe('AppContainer State Management', () => { }); it('falls back to code restore for both-mode rewind with no client', async () => { - const harness = renderRewindHarness({ noGeminiClient: true }); + const harness = renderRewindHarness({ noLlmClient: true }); await runRewind(harness.target, 'both'); @@ -6185,7 +6185,7 @@ describe('AppContainer State Management', () => { it('surfaces unexpected outer errors through history', async () => { const harness = renderRewindHarness(); - vi.spyOn(mockConfig, 'getGeminiClient').mockImplementation(() => { + vi.spyOn(mockConfig, 'getLlmClient').mockImplementation(() => { throw new Error('client exploded'); }); @@ -6234,7 +6234,7 @@ describe('AppContainer State Management', () => { loadHistory: vi.fn(), truncateToItem: vi.fn(), }); - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'idle', submitQuery: vi.fn(), initError: null, @@ -6278,7 +6278,7 @@ describe('AppContainer State Management', () => { loadHistory: vi.fn(), truncateToItem: vi.fn(), }); - mockedUseGeminiStream.mockReturnValue({ + mockedUseLlmStream.mockReturnValue({ streamingState: 'idle', submitQuery: vi.fn(), initError: null, @@ -6686,8 +6686,8 @@ describe('AppContainer State Management', () => { ); // performMemoryRefresh is the 12th arg (index 11) passed to - // useGeminiStream by AppContainer. - const calls = mockedUseGeminiStream.mock.calls; + // useLlmStream by AppContainer. + const calls = mockedUseLlmStream.mock.calls; const performMemoryRefresh = calls[ calls.length - 1 ]![11] as () => Promise; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 05be5d67251..531f3aa0f70 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -163,10 +163,7 @@ import { import { clearScreen } from '../utils/stdioHelpers.js'; import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; -import { - useGeminiStream, - type CancelSubmitInfo, -} from './hooks/useGeminiStream.js'; +import { useLlmStream, type CancelSubmitInfo } from './hooks/use-llm-stream.js'; import type { TrackedExecutingToolCall } from './hooks/useReactToolScheduler.js'; import { useVim } from './hooks/vim.js'; import { @@ -374,7 +371,7 @@ export function useQueuedSubmissionDrain({ popNextSubmission: UseMessageQueueReturn['popNextSubmission']; enqueueGoalTurn: UseMessageQueueReturn['enqueueGoalTurn']; restoreMessages: UseMessageQueueReturn['restoreMessages']; - submitQuery: ReturnType['submitQuery']; + submitQuery: ReturnType['submitQuery']; submissionInFlightRef: RefObject; submissionSettledRevision: number; }) { @@ -537,11 +534,11 @@ export function getSpeculativeToolResult(response: unknown): { } function getResponseCandidateTokens( - pendingGeminiHistoryItems: HistoryItemWithoutId[], + pendingLlmHistoryItems: HistoryItemWithoutId[], ): number { let tokens = 0; - for (const item of pendingGeminiHistoryItems) { + for (const item of pendingLlmHistoryItems) { if (item.type !== 'tool_group') { continue; } @@ -962,7 +959,7 @@ export const AppContainer = (props: AppContainerProps) => { // the profile captures the full MCP timeline without holding back // the user-facing TTI. - // Phase D-1: when launched with --worktree, gemini.tsx stashes a + // Phase D-1: when launched with --worktree, llm.tsx stashes a // one-shot notice on Config. Consume it here so it surfaces in the // transcript AND gets injected into the next user prompt. This // wins over the Phase C resume-restore path below — startup beats @@ -1093,7 +1090,7 @@ export const AppContainer = (props: AppContainerProps) => { * * 1. **16ms batch-flush of `setTools()`**: as each MCP server completes * discover, `McpClientManager` emits `mcp-client-update`. We coalesce - * these into at most one `GeminiClient.setTools()` call per ~16ms + * these into at most one `LlmClient.setTools()` call per ~16ms * window. With three MCP servers settling within a few ms of each * other, the model sees one consolidated tool refresh instead of * three back-to-back; with a server stream over 1s, the model sees @@ -1114,8 +1111,8 @@ export const AppContainer = (props: AppContainerProps) => { */ useEffect(() => { if (!isConfigInitialized) return undefined; - const geminiClient = config.getGeminiClient(); - if (!geminiClient) return undefined; + const llmClient = config.getLlmClient(); + if (!llmClient) return undefined; const manager = config.getToolRegistry().getMcpClientManager(); let flushTimer: NodeJS.Timeout | null = null; @@ -1136,11 +1133,11 @@ export const AppContainer = (props: AppContainerProps) => { clearTimeout(flushTimer); flushTimer = null; } - // GeminiClient.setTools() has no try/catch around warmAll() / + // LlmClient.setTools() has no try/catch around warmAll() / // getFunctionDeclarations() / getChat().setTools(). A silent // discard here would make production tool-registration regressions // invisible, so route the error through debugLogger. - return geminiClient.setTools().catch((err) => { + return llmClient.setTools().catch((err) => { debugLogger.error( `setTools() batch-flush failed: ${err instanceof Error ? err.message : String(err)}`, ); @@ -1155,7 +1152,7 @@ export const AppContainer = (props: AppContainerProps) => { }, MCP_BATCH_FLUSH_MS); }; - // Match the non-interactive entry points (`gemini.tsx`, `session.ts`, + // Match the non-interactive entry points (`llm.tsx`, `session.ts`, // `acpAgent.ts`) which warn to stderr when MCP discovery completes with // failed servers. The interactive path can't use stderr (it would // collide with Ink's rendered output), so we route through @@ -1221,9 +1218,9 @@ export const AppContainer = (props: AppContainerProps) => { // Track idle state via ref so the update handler can defer notifications // while the model is streaming, without triggering re-renders. // Note: isIdleRef.current is assigned after streamingState becomes available - // (see the assignment below useGeminiStream). + // (see the assignment below useLlmStream). const isIdleRef = useRef(true); - // Live content-area height, kept in a ref so useGeminiStream (called above the + // Live content-area height, kept in a ref so useLlmStream (called above the // point where availableTerminalHeight is computed) can read the current value // when bounding the pending item's rendered height. terminalWidthRef pairs // with it so the commit loop reads width and height consistently (both live). @@ -1845,7 +1842,7 @@ export const AppContainer = (props: AppContainerProps) => { // Signal the client to skip background memory tasks (extract, dream, // skill review) so the process can exit without spawning new agent // work during the exit window. - config.getGeminiClient()?.requestShutdown(); + config.getLlmClient()?.requestShutdown(); setTimeout(async () => { await runExitCleanup(); process.exit(0); @@ -2184,7 +2181,7 @@ export const AppContainer = (props: AppContainerProps) => { streamingState, submitQuery, initError, - pendingHistoryItems: pendingGeminiHistoryItems, + pendingHistoryItems: pendingLlmHistoryItems, clearPendingState, thought, cancelOngoingRequest, @@ -2196,8 +2193,8 @@ export const AppContainer = (props: AppContainerProps) => { pendingToolCalls, streamingResponseLengthRef, isReceivingContent, - } = useGeminiStream( - config.getGeminiClient(), + } = useLlmStream( + config.getLlmClient(), historyManager.history, historyManager.addItem, config, @@ -2319,7 +2316,7 @@ export const AppContainer = (props: AppContainerProps) => { }, []); // Auto-accept indicator — disabled on agent tabs (agents handle their own) - const geminiClient = config.getGeminiClient(); + const llmClient = config.getLlmClient(); const showAutoAcceptIndicator = useAutoAcceptIndicator({ config, @@ -2714,7 +2711,7 @@ export const AppContainer = (props: AppContainerProps) => { spec.status === 'completed' ) { // Accept completed speculation: inject messages and apply files - acceptSpeculation(spec, geminiClient) + acceptSpeculation(spec, llmClient) .then((result) => { logSpeculation( config, @@ -2849,7 +2846,7 @@ export const AppContainer = (props: AppContainerProps) => { handleSlashCommand, slashCommands, config, - geminiClient, + llmClient, historyManager, settings.merged.ui?.disableWorkflowKeywordTrigger, setBufferText, @@ -2877,8 +2874,8 @@ export const AppContainer = (props: AppContainerProps) => { } = useWelcomeBack(config, handleFinalSubmit, buffer, settings.merged); const pendingHistoryItems = useMemo( - () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], - [pendingSlashCommandHistoryItems, pendingGeminiHistoryItems], + () => [...pendingSlashCommandHistoryItems, ...pendingLlmHistoryItems], + [pendingSlashCommandHistoryItems, pendingLlmHistoryItems], ); const rawStickyTodos = useMemo( () => getStickyTodos(historyManager.history, pendingHistoryItems), @@ -2894,14 +2891,14 @@ export const AppContainer = (props: AppContainerProps) => { cancelHandlerRef.current = useCallback( (info?: CancelSubmitInfo) => { // Combine the React-state pending items (slash command, retry countdown, - // tool group, etc.) with the synchronous snapshot of the Gemini pending - // item from `useGeminiStream`. The snapshot closes the race where a + // tool group, etc.) with the synchronous snapshot of the LLM pending + // item from `useLlmStream`. The snapshot closes the race where a // stream chunk just set `pendingHistoryItem` but the consumer's React // state still reads as empty — without it, auto-restore could wrongly // truncate just-committed meaningful content. const pendingHistoryItems: HistoryItemWithoutId[] = [ ...pendingSlashCommandHistoryItems, - ...pendingGeminiHistoryItems, + ...pendingLlmHistoryItems, ]; if (info?.pendingItem) { pendingHistoryItems.push(info.pendingItem); @@ -2936,7 +2933,7 @@ export const AppContainer = (props: AppContainerProps) => { // the strip only pops trailing user entries, and a responded prompt is // not trailing. if (info?.wasGoalTurn) { - geminiClient?.stripOrphanedUserEntriesFromHistory?.(); + llmClient?.stripOrphanedUserEntriesFromHistory?.(); } // Restore-on-cancel: pull the just-submitted prompt back into the input @@ -3002,7 +2999,7 @@ export const AppContainer = (props: AppContainerProps) => { } // Synchronous "did the turn produce any content event" flag from - // useGeminiStream. Catches the race where the pre-cancel flush + // useLlmStream. Catches the race where the pre-cancel flush // committed gemini_content via addItem and a later thought event // overwrote pendingHistoryItem with a synthetic value — the // committed text isn't in historyRef.current yet (React hasn't @@ -3074,17 +3071,17 @@ export const AppContainer = (props: AppContainerProps) => { // in the input buffer. refreshStatic(); restoreCancelledPrompt(); - // Third cleanup leg: the in-memory chat history. `GeminiChat` + // Third cleanup leg: the in-memory chat history. `LlmChat` // appends the user content before the stream generator runs, and // the abort path doesn't pop it. Without this strip, the NEXT // request's wire payload would carry the cancelled prompt as an // orphan user turn alongside the new one — model context would // contradict what the UI told the user was rewound. Mirrors the // existing strip in the Retry submit path - // (GeminiClient.sendMessageStream). - geminiClient?.stripOrphanedUserEntriesFromHistory?.(); + // (LlmClient.sendMessageStream). + llmClient?.stripOrphanedUserEntriesFromHistory?.(); // Also undo the cross-session ↑-history disk entry written by - // useGeminiStream's `logger.logMessage` — otherwise + // useLlmStream's `logger.logMessage` — otherwise // getPreviousUserMessages would resurrect the cancelled prompt next // session. Fire-and-forget; the UI restore must not block on disk // I/O. Logger.removeLastUserMessage already swallows internal @@ -3104,10 +3101,10 @@ export const AppContainer = (props: AppContainerProps) => { removeGoalTurns, historyManager, logger, - geminiClient, + llmClient, refreshStatic, pendingSlashCommandHistoryItems, - pendingGeminiHistoryItems, + pendingLlmHistoryItems, ], ); @@ -3176,7 +3173,7 @@ export const AppContainer = (props: AppContainerProps) => { !isEditorDialogOpen && !showWelcomeBackDialog && welcomeBackChoice !== 'restart' && - geminiClient?.isInitialized?.() + llmClient?.isInitialized?.() ) { handleFinalSubmit(initialPrompt); initialPromptSubmitted.current = true; @@ -3191,7 +3188,7 @@ export const AppContainer = (props: AppContainerProps) => { isEditorDialogOpen, showWelcomeBackDialog, welcomeBackChoice, - geminiClient, + llmClient, ]); // Generate prompt suggestions when streaming completes. Enabled by default: @@ -3233,10 +3230,10 @@ export const AppContainer = (props: AppContainerProps) => { prevStreamingStateRef.current === StreamingState.Responding && streamingState === StreamingState.Idle && // Check both committed history and pending items for errors - // (API errors go to pendingGeminiHistoryItems, not historyManager.history) + // (API errors go to pendingLlmHistoryItems, not historyManager.history) historyManager.history[historyManager.history.length - 1]?.type !== 'error' && - !pendingGeminiHistoryItems.some((item) => item.type === 'error') && + !pendingLlmHistoryItems.some((item) => item.type === 'error') && !shellConfirmationRequest && !confirmationRequest && !loopDetectionConfirmationRequest && @@ -3249,7 +3246,7 @@ export const AppContainer = (props: AppContainerProps) => { // Only clone the tail — full structuredClone of a large resumed session // causes transient heap peaks that trigger OOM (#4624). - const conversationHistory = geminiClient.getHistoryTail(40, true); + const conversationHistory = llmClient.getHistoryTail(40, true); generatePromptSuggestion(config, conversationHistory, ac.signal, { // On by default: the schema declares `default: true`, but // `mergeSettings` doesn't apply schema defaults, so an unset value is @@ -3548,7 +3545,7 @@ export const AppContainer = (props: AppContainerProps) => { mainContentHeightReservation - tabBarHeight, ); - // Expose to useGeminiStream (called earlier) for rendered-height-aware commit. + // Expose to useLlmStream (called earlier) for rendered-height-aware commit. availableTerminalHeightRef.current = availableTerminalHeight; terminalWidthRef.current = terminalWidth; @@ -3645,13 +3642,11 @@ export const AppContainer = (props: AppContainerProps) => { // the conversation stays at the newer state. const needsConversation = option === 'conversation' || option === 'both'; - const geminiClient = needsConversation - ? config.getGeminiClient() - : null; + const llmClient = needsConversation ? config.getLlmClient() : null; let apiTruncateIndex = -1; let conversationSkippedNoClient = false; if (needsConversation) { - if (!geminiClient) { + if (!llmClient) { if (option === 'conversation') { historyManager.addItem( { @@ -3671,7 +3666,7 @@ export const AppContainer = (props: AppContainerProps) => { apiTruncateIndex = computeApiTruncationIndex( historyManager.history, userItem.id, - geminiClient.getHistoryShallow(), + llmClient.getHistoryShallow(), ); if (apiTruncateIndex < 0) { historyManager.addItem( @@ -3703,7 +3698,7 @@ export const AppContainer = (props: AppContainerProps) => { if (promptId) { try { const truncateHistory = - option === 'both' && !!geminiClient && apiTruncateIndex >= 0; + option === 'both' && !!llmClient && apiTruncateIndex >= 0; const result = await config .getFileHistoryService() .rewind(promptId, truncateHistory); @@ -3744,7 +3739,7 @@ export const AppContainer = (props: AppContainerProps) => { // Skip if file restore had failures in "both" mode to avoid inconsistent state. if ( needsConversation && - geminiClient && + llmClient && apiTruncateIndex >= 0 && !(option === 'both' && hasRestoreFailure) ) { @@ -3762,7 +3757,7 @@ export const AppContainer = (props: AppContainerProps) => { if (isRealUserTurn(h)) targetTurnIndex++; } - geminiClient.truncateHistory(apiTruncateIndex); + llmClient.truncateHistory(apiTruncateIndex); // Strip suppressOnRestore flags and filter out collapse-summary items // so rewound items remain visible without stale summary text @@ -3959,7 +3954,7 @@ export const AppContainer = (props: AppContainerProps) => { ); const responseCandidateTokens = getResponseCandidateTokens( - pendingGeminiHistoryItems, + pendingLlmHistoryItems, ); const { @@ -4436,7 +4431,7 @@ export const AppContainer = (props: AppContainerProps) => { // line ~448 — Ink v6.2.3 proxies can mangle binary escape sequences). writeTerminalTitle((value) => process.stdout.write(value), title); } - // Exit cleanup is handled by setWindowTitle() in gemini.tsx → process.on('exit') + // Exit cleanup is handled by setWindowTitle() in llm.tsx → process.on('exit') }, [ sessionName, streamingState, @@ -4513,7 +4508,7 @@ export const AppContainer = (props: AppContainerProps) => { memoryFileCount, streamingState, initError, - pendingGeminiHistoryItems, + pendingLlmHistoryItems, thought, shellModeActive, userMessages, @@ -4659,7 +4654,7 @@ export const AppContainer = (props: AppContainerProps) => { memoryFileCount, streamingState, initError, - pendingGeminiHistoryItems, + pendingLlmHistoryItems, thought, shellModeActive, userMessages, diff --git a/packages/cli/src/ui/commands/advisor-command.test.ts b/packages/cli/src/ui/commands/advisor-command.test.ts index 9bb0e2f0ede..15ff33f00e5 100644 --- a/packages/cli/src/ui/commands/advisor-command.test.ts +++ b/packages/cli/src/ui/commands/advisor-command.test.ts @@ -68,7 +68,7 @@ describe('advisorCommand', () => { let mockContext: CommandContext; const createConfig = (overrides: Record = {}) => ({ - getGeminiClient: () => ({ + getLlmClient: () => ({ getHistoryForForkWindow: () => [ { role: 'user', parts: [{ text: 'hello' }] }, ], @@ -335,7 +335,7 @@ describe('advisorCommand', () => { mockContext = createMockCommandContext({ services: { config: createConfig({ - getGeminiClient: () => ({ + getLlmClient: () => ({ getHistoryForForkWindow: () => [], }), }), diff --git a/packages/cli/src/ui/commands/advisor-command.ts b/packages/cli/src/ui/commands/advisor-command.ts index 45c65441060..aa44fa0b2c8 100644 --- a/packages/cli/src/ui/commands/advisor-command.ts +++ b/packages/cli/src/ui/commands/advisor-command.ts @@ -106,7 +106,7 @@ async function askAdvisor( const cacheSafeParams = buildBtwCacheSafeParams(config); if ( !cacheSafeParams || - config.getGeminiClient().getHistoryForForkWindow().length === 0 + config.getLlmClient().getHistoryForForkWindow().length === 0 ) { throw new Error(t('No conversation context available for /advisor')); } diff --git a/packages/cli/src/ui/commands/arenaCommand.agentComplete.test.ts b/packages/cli/src/ui/commands/arenaCommand.agentComplete.test.ts index 353c55331f8..c79cb20908e 100644 --- a/packages/cli/src/ui/commands/arenaCommand.agentComplete.test.ts +++ b/packages/cli/src/ui/commands/arenaCommand.agentComplete.test.ts @@ -65,7 +65,7 @@ describe('arenaCommand agent completion history', () => { getAvailableModelsForAuthType: vi.fn(() => []), })), getApprovalMode: vi.fn(() => 'default'), - getGeminiClient: vi.fn(() => ({ + getLlmClient: vi.fn(() => ({ getChat: vi.fn(() => ({ getHistoryShallow: vi.fn(() => []), })), diff --git a/packages/cli/src/ui/commands/arenaCommand.ts b/packages/cli/src/ui/commands/arenaCommand.ts index 656e59ee5b1..7208e01db7a 100644 --- a/packages/cli/src/ui/commands/arenaCommand.ts +++ b/packages/cli/src/ui/commands/arenaCommand.ts @@ -205,7 +205,7 @@ function executeArenaCommand( // its worktree directory — keeping the parent's would duplicate it. let chatHistory; try { - const fullHistory = config.getGeminiClient().getChat().getHistoryShallow(); + const fullHistory = config.getLlmClient().getChat().getHistoryShallow(); chatHistory = stripStartupContext(fullHistory); } catch { debugLogger.debug('Could not retrieve chat history for arena agents'); diff --git a/packages/cli/src/ui/commands/btwCommand.test.ts b/packages/cli/src/ui/commands/btwCommand.test.ts index d5ba2aa7ab9..51318918a10 100644 --- a/packages/cli/src/ui/commands/btwCommand.test.ts +++ b/packages/cli/src/ui/commands/btwCommand.test.ts @@ -57,7 +57,7 @@ describe('btwCommand', () => { let mockContext: CommandContext; const createConfig = (overrides: Record = {}) => ({ - getGeminiClient: () => ({}), + getLlmClient: () => ({}), getModel: () => 'test-model', getSessionId: () => 'test-session-id', getApprovalMode: () => 'default', diff --git a/packages/cli/src/ui/commands/cdCommand.test.ts b/packages/cli/src/ui/commands/cdCommand.test.ts index 28c5592c1ce..9859fb929c9 100644 --- a/packages/cli/src/ui/commands/cdCommand.test.ts +++ b/packages/cli/src/ui/commands/cdCommand.test.ts @@ -55,7 +55,7 @@ describe('cdCommand', () => { getWorkingDir: () => currentDir, isRestrictiveSandbox: () => false, relocateWorkingDirectory, - getGeminiClient: () => ({ + getLlmClient: () => ({ addWorkingDirectoryChangedContext, }), } as unknown as Config, @@ -164,7 +164,7 @@ describe('cdCommand', () => { getWorkingDir: () => currentDir, isRestrictiveSandbox: () => true, relocateWorkingDirectory, - getGeminiClient: () => ({ + getLlmClient: () => ({ addWorkingDirectoryChangedContext, }), } as unknown as Config, diff --git a/packages/cli/src/ui/commands/cdCommand.ts b/packages/cli/src/ui/commands/cdCommand.ts index 08d0e1ee068..03f11cd8825 100644 --- a/packages/cli/src/ui/commands/cdCommand.ts +++ b/packages/cli/src/ui/commands/cdCommand.ts @@ -212,7 +212,7 @@ export const cdCommand: SlashCommand = { try { await config - .getGeminiClient() + .getLlmClient() ?.addWorkingDirectoryChangedContext(realOldDir, realTargetPath); } catch (error) { warnings.push( diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index 83053dc3a31..4ddc7ac5dde 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -24,7 +24,7 @@ vi.mock('@qwen-code/qwen-code-core', async () => { }; }); -import type { GeminiClient } from '@qwen-code/qwen-code-core'; +import type { LlmClient } from '@qwen-code/qwen-code-core'; describe('clearCommand', () => { let mockContext: CommandContext; @@ -60,10 +60,10 @@ describe('clearCommand', () => { mockContext = createMockCommandContext({ services: { config: { - getGeminiClient: () => + getLlmClient: () => ({ resetChat: mockResetChat, - }) as unknown as GeminiClient, + }) as unknown as LlmClient, getBackgroundTaskRegistry: vi.fn().mockReturnValue({ hasRunningTasks: vi.fn().mockReturnValue(false), reset: mockResetBackgroundTasks, @@ -335,9 +335,9 @@ describe('clearCommand', () => { abortAll: mockAbortBackgroundShells, }), startNewSession: mockStartNewSession, - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ resetChat: mockResetChat, - } as unknown as GeminiClient), + } as unknown as LlmClient), getModel: vi.fn().mockReturnValue('test-model'), getApprovalMode: vi.fn().mockReturnValue('default'), getToolRegistry: vi.fn().mockReturnValue({ @@ -433,9 +433,9 @@ describe('clearCommand', () => { }), getHookSystem: mockGetHookSystem, startNewSession: mockStartNewSession, - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ resetChat: mockResetChat, - } as unknown as GeminiClient), + } as unknown as LlmClient), getModel: vi.fn().mockReturnValue('test-model'), getApprovalMode: vi.fn().mockReturnValue('default'), getToolRegistry: vi.fn().mockReturnValue({ @@ -506,9 +506,9 @@ describe('clearCommand', () => { }), getHookSystem: mockGetHookSystem, startNewSession: mockStartNewSession, - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ resetChat: mockResetChat, - } as unknown as GeminiClient), + } as unknown as LlmClient), getModel: vi.fn().mockReturnValue('test-model'), getApprovalMode: vi.fn().mockReturnValue('default'), getToolRegistry: vi.fn().mockReturnValue({ @@ -575,9 +575,9 @@ describe('clearCommand', () => { }), getHookSystem: mockGetHookSystem, startNewSession: mockStartNewSession, - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ resetChat: mockResetChat, - } as unknown as GeminiClient), + } as unknown as LlmClient), getModel: vi.fn().mockReturnValue('test-model'), getApprovalMode: vi.fn().mockReturnValue('default'), getToolRegistry: vi.fn().mockReturnValue({ @@ -645,9 +645,9 @@ describe('clearCommand', () => { }), getHookSystem: mockGetHookSystem, startNewSession: mockStartNewSession, - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ resetChat: mockResetChat, - } as unknown as GeminiClient), + } as unknown as LlmClient), getModel: vi.fn().mockReturnValue('test-model'), getApprovalMode: vi.fn().mockReturnValue('default'), getToolRegistry: vi.fn().mockReturnValue({ diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index e95eff58ea3..4cd299e27fc 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -116,14 +116,14 @@ export const clearCommand: SlashCommand = { // Clear UI first for immediate responsiveness context.ui.clear(); - const geminiClient = config.getGeminiClient(); - if (geminiClient) { + const llmClient = config.getLlmClient(); + if (llmClient) { context.ui.setDebugMessage( t('Starting a new session, resetting chat, and clearing terminal.'), ); // If resetChat fails, the exception will propagate and halt the command, // which is the correct behavior to signal a failure to the user. - await geminiClient.resetChat(); + await llmClient.resetChat(); } else { context.ui.setDebugMessage(t('Starting a new session and clearing.')); } diff --git a/packages/cli/src/ui/commands/compressCommand.test.ts b/packages/cli/src/ui/commands/compressCommand.test.ts index b80fcc30671..f8dc9386894 100644 --- a/packages/cli/src/ui/commands/compressCommand.test.ts +++ b/packages/cli/src/ui/commands/compressCommand.test.ts @@ -7,7 +7,7 @@ import { CompressionStatus, type ChatCompressionInfo, - type GeminiClient, + type LlmClient, } from '@qwen-code/qwen-code-core'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import { compressCommand } from './compressCommand.js'; @@ -23,10 +23,10 @@ describe('compressCommand', () => { context = createMockCommandContext({ services: { config: { - getGeminiClient: () => + getLlmClient: () => ({ tryCompressChat: mockTryCompressChat, - }) as unknown as GeminiClient, + }) as unknown as LlmClient, }, }, }); @@ -168,10 +168,10 @@ describe('compressCommand', () => { const ctx = createMockCommandContext({ services: { config: { - getGeminiClient: () => + getLlmClient: () => ({ tryCompressChat: mockTryCompressChat, - }) as unknown as GeminiClient, + }) as unknown as LlmClient, }, }, invocation: { @@ -193,10 +193,10 @@ describe('compressCommand', () => { const ctx = createMockCommandContext({ services: { config: { - getGeminiClient: () => + getLlmClient: () => ({ tryCompressChat: mockTryCompressChat, - }) as unknown as GeminiClient, + }) as unknown as LlmClient, }, }, invocation: { raw: '/compress ', name: 'compress', args: ' ' }, @@ -215,10 +215,10 @@ describe('compressCommand', () => { const ctx = createMockCommandContext({ services: { config: { - getGeminiClient: () => + getLlmClient: () => ({ tryCompressChat: mockTryCompressChat, - }) as unknown as GeminiClient, + }) as unknown as LlmClient, }, }, invocation: { @@ -238,10 +238,10 @@ describe('compressCommand', () => { const ctx = createMockCommandContext({ services: { config: { - getGeminiClient: () => + getLlmClient: () => ({ tryCompressChat: mockTryCompressChat, - }) as unknown as GeminiClient, + }) as unknown as LlmClient, }, }, invocation: { raw: `/compress ${long}`, name: 'compress', args: long }, @@ -260,10 +260,10 @@ describe('compressCommand', () => { const ctx = createMockCommandContext({ services: { config: { - getGeminiClient: () => + getLlmClient: () => ({ tryCompressChat: mockTryCompressChat, - }) as unknown as GeminiClient, + }) as unknown as LlmClient, }, }, invocation: { diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts index b85a3f7ae49..fa10233bf12 100644 --- a/packages/cli/src/ui/commands/compressCommand.ts +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -52,8 +52,8 @@ export const compressCommand: SlashCommand = { }; const config = context.services.config; - const geminiClient = config?.getGeminiClient(); - if (!config || !geminiClient) { + const llmClient = config?.getLlmClient(); + if (!config || !llmClient) { return { type: 'message', messageType: 'error', @@ -77,7 +77,7 @@ export const compressCommand: SlashCommand = { const doCompress = async () => { const promptId = `compress-${Date.now()}`; - return await geminiClient.tryCompressChat( + return await llmClient.tryCompressChat( promptId, true, abortSignal, diff --git a/packages/cli/src/ui/commands/compressFastCommand.test.ts b/packages/cli/src/ui/commands/compressFastCommand.test.ts index 4714fd3bdff..dd53f84189f 100644 --- a/packages/cli/src/ui/commands/compressFastCommand.test.ts +++ b/packages/cli/src/ui/commands/compressFastCommand.test.ts @@ -7,7 +7,7 @@ import { CompressionStatus, type ChatCompressionInfo, - type GeminiClient, + type LlmClient, } from '@qwen-code/qwen-code-core'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import { compressFastCommand } from './compressFastCommand.js'; @@ -23,10 +23,10 @@ describe('compressFastCommand', () => { context = createMockCommandContext({ services: { config: { - getGeminiClient: () => + getLlmClient: () => ({ tryCompressChatFast: mockTryCompressChatFast, - }) as unknown as GeminiClient, + }) as unknown as LlmClient, }, }, }); @@ -75,10 +75,10 @@ describe('compressFastCommand', () => { }, services: { config: { - getGeminiClient: () => + getLlmClient: () => ({ tryCompressChatFast: mockTryCompressChatFast, - }) as unknown as GeminiClient, + }) as unknown as LlmClient, }, }, }); @@ -108,10 +108,10 @@ describe('compressFastCommand', () => { }, services: { config: { - getGeminiClient: () => + getLlmClient: () => ({ tryCompressChatFast: mockTryCompressChatFast, - }) as unknown as GeminiClient, + }) as unknown as LlmClient, }, }, }); @@ -205,10 +205,10 @@ describe('compressFastCommand', () => { executionMode: 'non_interactive', services: { config: { - getGeminiClient: () => + getLlmClient: () => ({ tryCompressChatFast: mockTryCompressChatFast, - }) as unknown as GeminiClient, + }) as unknown as LlmClient, }, }, }); @@ -233,10 +233,10 @@ describe('compressFastCommand', () => { executionMode: 'non_interactive', services: { config: { - getGeminiClient: () => + getLlmClient: () => ({ tryCompressChatFast: mockTryCompressChatFast, - }) as unknown as GeminiClient, + }) as unknown as LlmClient, }, }, }); diff --git a/packages/cli/src/ui/commands/compressFastCommand.ts b/packages/cli/src/ui/commands/compressFastCommand.ts index ab6b199f56d..0f5b39a464c 100644 --- a/packages/cli/src/ui/commands/compressFastCommand.ts +++ b/packages/cli/src/ui/commands/compressFastCommand.ts @@ -54,8 +54,8 @@ export const compressFastCommand: SlashCommand = { }; const config = context.services.config; - const geminiClient = config?.getGeminiClient(); - if (!config || !geminiClient) { + const llmClient = config?.getLlmClient(); + if (!config || !llmClient) { return { type: 'message', messageType: 'error', @@ -63,7 +63,7 @@ export const compressFastCommand: SlashCommand = { }; } - const doCompress = async () => await geminiClient.tryCompressChatFast(); + const doCompress = async () => await llmClient.tryCompressChatFast(); if (executionMode === 'acp') { const messages = async function* () { diff --git a/packages/cli/src/ui/commands/contextCommand.test.ts b/packages/cli/src/ui/commands/contextCommand.test.ts index 8e3e0b972a1..b430f4d6244 100644 --- a/packages/cli/src/ui/commands/contextCommand.test.ts +++ b/packages/cli/src/ui/commands/contextCommand.test.ts @@ -128,7 +128,7 @@ describe('collectContextData (contextCommand)', () => { const isLastPromptTokenCountEstimated = vi.fn().mockReturnValue(false); const config = { ...makeMockConfig(200_000), - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(true), getChat: vi.fn().mockReturnValue({ getLastPromptTokenCount, @@ -148,7 +148,7 @@ describe('collectContextData (contextCommand)', () => { it('reports a nonzero compression-derived count as estimated', async () => { const config = { ...makeMockConfig(200_000), - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(true), getChat: vi.fn().mockReturnValue({ getLastPromptTokenCount: vi.fn().mockReturnValue(50_000), @@ -173,7 +173,7 @@ describe('collectContextData (contextCommand)', () => { mockGetLastPromptTokenCount.mockReturnValue(60_000); const config = { ...makeMockConfig(200_000), - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(false), getChat: vi.fn(() => { throw new Error('Chat not initialized'); diff --git a/packages/cli/src/ui/commands/contextCommand.ts b/packages/cli/src/ui/commands/contextCommand.ts index d4dc99e20bb..17107deac3c 100644 --- a/packages/cli/src/ui/commands/contextCommand.ts +++ b/packages/cli/src/ui/commands/contextCommand.ts @@ -34,7 +34,7 @@ import * as path from 'node:path'; /** * Classify a token count against the three-tier compaction ladder. Mirrors - * the gating logic in `chatCompressionService` / `geminiChat` so the + * the gating logic in `chatCompressionService` / `llmChat` so the * `/context` output's "current tier" label reflects exactly which tier the * runtime would treat the session as sitting in. */ @@ -126,15 +126,15 @@ export async function collectContextData( // (#5763). The active chat carries the correct per-session value; fall back // to the global singleton only when no chat exists yet (first /context, // --continue resume before any send). - const geminiClient = config.getGeminiClient?.(); - const activeChat = geminiClient?.isInitialized?.() - ? geminiClient.getChat() + const llmClient = config.getLlmClient?.(); + const activeChat = llmClient?.isInitialized?.() + ? llmClient.getChat() : undefined; const apiTotalTokens = activeChat ? activeChat.getLastPromptTokenCount() : uiTelemetryService.getLastPromptTokenCount(); // Cached-content tokens have no per-chat mirror today (only the global - // singleton is written, geminiChat.ts), so this read stays global. It only + // singleton is written, llm-chat.ts), so this read stays global. It only // refines the messages-vs-cache split, not the headline total or tier. const apiCachedTokens = uiTelemetryService.getLastCachedContentTokenCount(); diff --git a/packages/cli/src/ui/commands/copyCommand.test.ts b/packages/cli/src/ui/commands/copyCommand.test.ts index ff13af272af..ace5006745e 100644 --- a/packages/cli/src/ui/commands/copyCommand.test.ts +++ b/packages/cli/src/ui/commands/copyCommand.test.ts @@ -33,7 +33,7 @@ describe('copyCommand', () => { mockContext = createMockCommandContext({ services: { config: { - getGeminiClient: () => ({ + getLlmClient: () => ({ getChat: mockGetChat, }), getDebugLogger: () => ({ diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index a7f236f0193..915da62e6b1 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -365,7 +365,7 @@ export const copyCommand: SlashCommand = { kind: CommandKind.BUILT_IN, supportedModes: ['interactive'] as const, action: async (context, args): Promise => { - const chat = await context.services.config?.getGeminiClient()?.getChat(); + const chat = await context.services.config?.getLlmClient()?.getChat(); const history = chat?.getHistoryShallow(); const aiMessages = history?.filter((item) => item.role === 'model') ?? []; diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index 5d44de70004..736e037baa6 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -76,7 +76,7 @@ describe('directoryCommand', () => { mockConfig = { getWorkspaceContext: () => mockWorkspaceContext, isRestrictiveSandbox: vi.fn().mockReturnValue(false), - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ addDirectoryContext: vi.fn(), }), getWorkingDir: () => '/test/dir', @@ -450,11 +450,11 @@ describe('directoryCommand', () => { }); it('should warn when gemini.addDirectoryContext throws', async () => { - vi.mocked(mockConfig.getGeminiClient).mockReturnValue({ + vi.mocked(mockConfig.getLlmClient).mockReturnValue({ addDirectoryContext: vi .fn() .mockRejectedValue(new Error('gemini unavailable')), - } as unknown as ReturnType); + } as unknown as ReturnType); const newPath = path.normalize('/home/user/new-project'); if (!addCommand?.action) throw new Error('No action'); const result = await addCommand.action(mockContext, newPath); diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index ef8e9be1709..65a16b0d2ed 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -281,7 +281,7 @@ export const directoryCommand: SlashCommand = { } if (added.length > 0) { - const gemini = config.getGeminiClient(); + const gemini = config.getLlmClient(); if (gemini) { try { await gemini.addDirectoryContext(); diff --git a/packages/cli/src/ui/commands/doctorChecks.test.ts b/packages/cli/src/ui/commands/doctorChecks.test.ts index 0c41cc0ae85..c3fe462af12 100644 --- a/packages/cli/src/ui/commands/doctorChecks.test.ts +++ b/packages/cli/src/ui/commands/doctorChecks.test.ts @@ -38,7 +38,7 @@ describe('runDoctorChecks', () => { services: { config: { getAuthType: vi.fn().mockReturnValue('openai'), - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(true), }), getModel: vi.fn().mockReturnValue('gpt-4'), @@ -110,7 +110,7 @@ describe('runDoctorChecks', () => { services: { config: { getAuthType: vi.fn().mockReturnValue(undefined), - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(false), }), getModel: vi.fn().mockReturnValue('gpt-4'), @@ -227,7 +227,7 @@ describe('runDoctorChecks', () => { services: { config: { getAuthType: vi.fn().mockReturnValue('openai'), - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(true), }), getModel: vi.fn().mockReturnValue('gpt-4'), @@ -253,7 +253,7 @@ describe('runDoctorChecks', () => { services: { config: { getAuthType: vi.fn().mockReturnValue('openai'), - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(true), }), getModel: vi.fn().mockReturnValue('gpt-4'), @@ -282,7 +282,7 @@ describe('runDoctorChecks', () => { services: { config: { getAuthType: vi.fn().mockReturnValue('openai'), - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(true), }), getModel: vi.fn().mockReturnValue('gpt-4'), @@ -313,7 +313,7 @@ describe('runDoctorChecks', () => { services: { config: { getAuthType: vi.fn().mockReturnValue('openai'), - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(true), }), getModel: vi.fn().mockReturnValue('gpt-4'), diff --git a/packages/cli/src/ui/commands/doctorChecks.ts b/packages/cli/src/ui/commands/doctorChecks.ts index 16aad5ea1fe..33465bb4e55 100644 --- a/packages/cli/src/ui/commands/doctorChecks.ts +++ b/packages/cli/src/ui/commands/doctorChecks.ts @@ -150,7 +150,7 @@ async function checkApiClient( } try { - const client = config.getGeminiClient(); + const client = config.getLlmClient(); if (client.isInitialized()) { return { category: t('Authentication'), diff --git a/packages/cli/src/ui/commands/doctorCommand.test.ts b/packages/cli/src/ui/commands/doctorCommand.test.ts index 21ee25e03e7..f9082ed78e7 100644 --- a/packages/cli/src/ui/commands/doctorCommand.test.ts +++ b/packages/cli/src/ui/commands/doctorCommand.test.ts @@ -821,7 +821,7 @@ describe('doctorCommand', () => { getSessionId: () => 'test-session', getCliVersion: () => '0.0.0', getTruncateToolOutputThreshold: () => 25_000, - getGeminiClient: () => ({ + getLlmClient: () => ({ getHistoryShallow: () => { if (options.historyThrows) { throw new Error('history unavailable'); @@ -1014,7 +1014,7 @@ describe('doctorCommand', () => { getSessionId: () => 'test-session', getCliVersion: () => '0.0.0', getTruncateToolOutputThreshold: () => Number.POSITIVE_INFINITY, - getGeminiClient: () => ({ + getLlmClient: () => ({ getHistoryShallow: () => [], }), getToolRegistry: () => ({ @@ -1108,7 +1108,7 @@ describe('doctorCommand', () => { config: { getSessionId: () => 'test-session', getCliVersion: () => '0.0.0', - getGeminiClient: () => ({ + getLlmClient: () => ({ getHistoryShallow: () => history, }), }, diff --git a/packages/cli/src/ui/commands/doctorCommand.ts b/packages/cli/src/ui/commands/doctorCommand.ts index e1af69c4d26..40f8d0b9476 100644 --- a/packages/cli/src/ui/commands/doctorCommand.ts +++ b/packages/cli/src/ui/commands/doctorCommand.ts @@ -475,7 +475,7 @@ function collectToolResultRetention( ): ToolResultRetentionReport | null { try { const config = context.services.config; - const history = config?.getGeminiClient()?.getHistoryShallow(); + const history = config?.getLlmClient()?.getHistoryShallow(); if (!history) { return null; } diff --git a/packages/cli/src/ui/commands/forkCommand.test.ts b/packages/cli/src/ui/commands/forkCommand.test.ts index fe703e1678b..3a0f40be807 100644 --- a/packages/cli/src/ui/commands/forkCommand.test.ts +++ b/packages/cli/src/ui/commands/forkCommand.test.ts @@ -41,7 +41,7 @@ describe('forkCommand', () => { ]; const createConfig = (overrides: Record = {}) => ({ - getGeminiClient: () => ({ + getLlmClient: () => ({ addHistory: mockAddHistory, getHistoryShallow: () => historyWithTurn, }), @@ -121,7 +121,7 @@ describe('forkCommand', () => { const fresh = createMockCommandContext({ services: { config: createConfig({ - getGeminiClient: () => ({ getHistoryShallow: () => [] }), + getLlmClient: () => ({ getHistoryShallow: () => [] }), }), }, }); @@ -137,7 +137,7 @@ describe('forkCommand', () => { const unreadableHistory = createMockCommandContext({ services: { config: createConfig({ - getGeminiClient: () => ({ + getLlmClient: () => ({ getHistoryShallow: () => { throw new Error('history unavailable'); }, diff --git a/packages/cli/src/ui/commands/forkCommand.ts b/packages/cli/src/ui/commands/forkCommand.ts index 8ceb5393061..b6a30037e14 100644 --- a/packages/cli/src/ui/commands/forkCommand.ts +++ b/packages/cli/src/ui/commands/forkCommand.ts @@ -92,8 +92,7 @@ export const forkCommand: SlashCommand = { // Guard: a fork inherits the conversation history; there must be one. let hasHistory = false; try { - hasHistory = - (config.getGeminiClient().getHistoryShallow() ?? []).length > 0; + hasHistory = (config.getLlmClient().getHistoryShallow() ?? []).length > 0; } catch (error) { debugLogger.debug('Failed to read history before /fork:', error); hasHistory = false; @@ -167,7 +166,7 @@ export const forkCommand: SlashCommand = { } try { - config.getGeminiClient().addHistory({ + config.getLlmClient().addHistory({ role: 'user', parts: [ { diff --git a/packages/cli/src/ui/commands/languageCommand.test.ts b/packages/cli/src/ui/commands/languageCommand.test.ts index e2689a92b3a..a7d036f7a0b 100644 --- a/packages/cli/src/ui/commands/languageCommand.test.ts +++ b/packages/cli/src/ui/commands/languageCommand.test.ts @@ -538,7 +538,7 @@ describe('languageCommand', () => { const refreshHierarchicalMemory = vi.fn().mockResolvedValue(undefined); const refreshSystemInstruction = vi.fn().mockResolvedValue(undefined); - const getGeminiClient = vi + const getLlmClient = vi .fn() .mockReturnValue({ refreshSystemInstruction }); ( @@ -550,7 +550,7 @@ describe('languageCommand', () => { getOutputLanguageFilePath: vi.fn().mockReturnValue(undefined), setOutputLanguageFilePath: vi.fn(), refreshHierarchicalMemory, - getGeminiClient, + getLlmClient, }; const result = await languageCommand.action(mockContext, 'output auto'); @@ -582,7 +582,7 @@ describe('languageCommand', () => { const refreshHierarchicalMemory = vi.fn().mockResolvedValue(undefined); const refreshSystemInstruction = vi.fn().mockResolvedValue(undefined); - const getGeminiClient = vi + const getLlmClient = vi .fn() .mockReturnValue({ refreshSystemInstruction }); ( @@ -594,7 +594,7 @@ describe('languageCommand', () => { getOutputLanguageFilePath: vi.fn().mockReturnValue(undefined), setOutputLanguageFilePath: vi.fn(), refreshHierarchicalMemory, - getGeminiClient, + getLlmClient, }; const result = await languageCommand.action( @@ -603,7 +603,7 @@ describe('languageCommand', () => { ); expect(refreshHierarchicalMemory).toHaveBeenCalledTimes(1); - expect(getGeminiClient).toHaveBeenCalledTimes(1); + expect(getLlmClient).toHaveBeenCalledTimes(1); expect(refreshSystemInstruction).toHaveBeenCalledTimes(1); // Memory MUST be refreshed before the system instruction is rebuilt; // otherwise the new instruction would be built from stale userMemory @@ -637,7 +637,7 @@ describe('languageCommand', () => { getOutputLanguageFilePath: vi.fn().mockReturnValue(undefined), setOutputLanguageFilePath: vi.fn(), refreshHierarchicalMemory, - // No getGeminiClient — refreshSystemInstruction must not be reached. + // No getLlmClient — refreshSystemInstruction must not be reached. }; const result = await languageCommand.action(mockContext, 'output Korean'); diff --git a/packages/cli/src/ui/commands/languageCommand.ts b/packages/cli/src/ui/commands/languageCommand.ts index c1e16011862..c11b382a933 100644 --- a/packages/cli/src/ui/commands/languageCommand.ts +++ b/packages/cli/src/ui/commands/languageCommand.ts @@ -221,7 +221,7 @@ async function setOutputLanguage( if (config) { try { await config.refreshHierarchicalMemory(); - await config.getGeminiClient().refreshSystemInstruction(); + await config.getLlmClient().refreshSystemInstruction(); } catch (error) { debugLogger.warn( 'Failed to apply output language to running session:', diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index 085376719e9..24ee9e2fe06 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -39,7 +39,7 @@ describe('mcpCommand', () => { getMcpServers: ReturnType; getBlockedMcpServers: ReturnType; getPromptRegistry: ReturnType; - getGeminiClient: ReturnType; + getLlmClient: ReturnType; }; beforeEach(() => { @@ -65,7 +65,7 @@ describe('mcpCommand', () => { getAllPrompts: vi.fn().mockReturnValue([]), getPromptsByServer: vi.fn().mockReturnValue([]), }), - getGeminiClient: vi.fn(), + getLlmClient: vi.fn(), }; mockContext = createMockCommandContext({ diff --git a/packages/cli/src/ui/commands/restoreCommand.test.ts b/packages/cli/src/ui/commands/restoreCommand.test.ts index aa10b631783..e050da0f2a8 100644 --- a/packages/cli/src/ui/commands/restoreCommand.test.ts +++ b/packages/cli/src/ui/commands/restoreCommand.test.ts @@ -44,7 +44,7 @@ describe('restoreCommand', () => { getProjectTempCheckpointsDir: vi.fn().mockReturnValue(checkpointsDir), getProjectTempDir: vi.fn().mockReturnValue(geminiTempDir), }, - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ setHistory: mockSetHistory, }), } as unknown as Config; diff --git a/packages/cli/src/ui/commands/restoreCommand.ts b/packages/cli/src/ui/commands/restoreCommand.ts index 8fc3c36f1a6..907581c4a33 100644 --- a/packages/cli/src/ui/commands/restoreCommand.ts +++ b/packages/cli/src/ui/commands/restoreCommand.ts @@ -149,7 +149,7 @@ async function restoreAction( } if (toolCallData.clientHistory) { - await config?.getGeminiClient()?.setHistory(toolCallData.clientHistory); + await config?.getLlmClient()?.setHistory(toolCallData.clientHistory); } return { diff --git a/packages/cli/src/ui/commands/summaryCommand.test.ts b/packages/cli/src/ui/commands/summaryCommand.test.ts index beebc4f4e01..2c0edf3d5ea 100644 --- a/packages/cli/src/ui/commands/summaryCommand.test.ts +++ b/packages/cli/src/ui/commands/summaryCommand.test.ts @@ -34,7 +34,7 @@ const makeContext = (projectRoot: string): CommandContext => { }; const config = { getProjectRoot: () => projectRoot, - getGeminiClient: () => ({ getChat: () => chat }), + getLlmClient: () => ({ getChat: () => chat }), getModel: () => 'test-model', }; return createMockCommandContext({ @@ -578,7 +578,7 @@ describe('summaryCommand custom export path', () => { }; const config = { getProjectRoot: () => projectRoot, - getGeminiClient: () => ({ getChat: () => chat }), + getLlmClient: () => ({ getChat: () => chat }), getModel: () => 'test-model', }; const context = createMockCommandContext({ diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index 603d0c69567..8549034600c 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -128,8 +128,8 @@ export const summaryCommand: SlashCommand = { }; } - const geminiClient = config.getGeminiClient(); - if (!geminiClient) { + const llmClient = config.getLlmClient(); + if (!llmClient) { return { type: 'message', messageType: 'error', @@ -158,7 +158,7 @@ export const summaryCommand: SlashCommand = { } const getChatHistory = () => { - const chat = geminiClient.getChat(); + const chat = llmClient.getChat(); return chat.getHistoryShallow(); }; @@ -182,8 +182,8 @@ export const summaryCommand: SlashCommand = { // Carry over the main session's system instruction. Without this the // model sees only chat history + the summary prompt, losing the coding- // assistant role, project context, and user memory. The chat sets it - // as a string (see GeminiClient.getMainSessionSystemInstruction). - const rawSystemInstruction = geminiClient + // as a string (see LlmClient.getMainSessionSystemInstruction). + const rawSystemInstruction = llmClient .getChat() .getGenerationConfig().systemInstruction; const chatSystemInstruction = diff --git a/packages/cli/src/ui/commands/toolsCommand.ts b/packages/cli/src/ui/commands/toolsCommand.ts index 90f6f2886ee..da630af44d1 100644 --- a/packages/cli/src/ui/commands/toolsCommand.ts +++ b/packages/cli/src/ui/commands/toolsCommand.ts @@ -42,11 +42,11 @@ export const toolsCommand: SlashCommand = { const tools = toolRegistry.getAllTools(); // Filter out MCP tools by checking for the absence of a serverName property - const geminiTools = tools.filter((tool) => !('serverName' in tool)); + const llmTools = tools.filter((tool) => !('serverName' in tool)); const toolsListItem: HistoryItemToolsList = { type: MessageType.TOOLS_LIST, - tools: geminiTools.map((tool) => ({ + tools: llmTools.map((tool) => ({ name: tool.name, displayName: tool.displayName, description: tool.description, diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index d6621dbc409..c2ccd0f86dc 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -240,7 +240,7 @@ export interface LoadHistoryActionReturn { /** * The return type for a command action that should immediately submit - * content as a prompt to the Gemini model. + * content as a prompt to the model. */ export interface SubmitPromptActionReturn { type: 'submit_prompt'; diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index db76376d343..58c5f3aa83c 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -149,7 +149,7 @@ const createMockUIState = (overrides: Partial = {}): UIState => streamingResponseLengthRef: { current: 0 }, voiceMicWarnedStatusRef: { current: null }, isReceivingContent: false, - pendingGeminiHistoryItems: [], + pendingLlmHistoryItems: [], terminalWidth: 80, ...overrides, }) as UIState; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index 87b19cee17e..41b617de26c 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -389,7 +389,7 @@ describe('', () => { expect(lastFrame()).toMatchSnapshot(); }); - it('should render a full gemini item when using availableTerminalHeightGemini', () => { + it('should render a full gemini item when using availableTerminalHeightLlm', () => { const item: HistoryItem = { id: 1, type: 'gemini', @@ -401,7 +401,7 @@ describe('', () => { isPending={false} terminalWidth={80} availableTerminalHeight={10} - availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER} + availableTerminalHeightLlm={Number.MAX_SAFE_INTEGER} />, ); @@ -426,7 +426,7 @@ describe('', () => { expect(lastFrame()).toMatchSnapshot(); }); - it('should render a full gemini_content item when using availableTerminalHeightGemini', () => { + it('should render a full gemini_content item when using availableTerminalHeightLlm', () => { const item: HistoryItem = { id: 1, type: 'gemini_content', @@ -438,7 +438,7 @@ describe('', () => { isPending={false} terminalWidth={80} availableTerminalHeight={10} - availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER} + availableTerminalHeightLlm={Number.MAX_SAFE_INTEGER} />, ); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 3b146266900..f5c19805495 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -83,7 +83,7 @@ interface HistoryItemDisplayProps { commands?: readonly SlashCommand[]; activeShellPtyId?: number | null; embeddedShellFocused?: boolean; - availableTerminalHeightGemini?: number; + availableTerminalHeightLlm?: number; sourceCopyIndexOffsets?: MarkdownSourceCopyIndexOffsets; /** Force thinking blocks expanded (e.g. in SessionPreview). */ thoughtExpanded?: boolean; @@ -237,7 +237,7 @@ const HistoryItemDisplayComponent: React.FC = ({ isFocused = true, activeShellPtyId, embeddedShellFocused, - availableTerminalHeightGemini, + availableTerminalHeightLlm, sourceCopyIndexOffsets, thoughtExpanded, fullDetail = false, @@ -306,7 +306,7 @@ const HistoryItemDisplayComponent: React.FC = ({ omittedImageCount={itemForDisplay.omittedImageCount} isPending={isPending} availableTerminalHeight={ - availableTerminalHeightGemini ?? availableTerminalHeight + availableTerminalHeightLlm ?? availableTerminalHeight } contentWidth={contentWidth} sourceCopyIndexOffsets={sourceCopyIndexOffsets} @@ -320,7 +320,7 @@ const HistoryItemDisplayComponent: React.FC = ({ omittedImageCount={itemForDisplay.omittedImageCount} isPending={isPending} availableTerminalHeight={ - availableTerminalHeightGemini ?? availableTerminalHeight + availableTerminalHeightLlm ?? availableTerminalHeight } contentWidth={contentWidth} sourceCopyIndexOffsets={sourceCopyIndexOffsets} @@ -332,7 +332,7 @@ const HistoryItemDisplayComponent: React.FC = ({ isPending={isPending} expanded={resolvedThoughtExpanded} availableTerminalHeight={ - availableTerminalHeightGemini ?? availableTerminalHeight + availableTerminalHeightLlm ?? availableTerminalHeight } contentWidth={contentWidth} durationMs={itemForDisplay.durationMs} @@ -345,7 +345,7 @@ const HistoryItemDisplayComponent: React.FC = ({ isPending={isPending} expanded={resolvedThoughtExpanded} availableTerminalHeight={ - availableTerminalHeightGemini ?? availableTerminalHeight + availableTerminalHeightLlm ?? availableTerminalHeight } contentWidth={contentWidth} /> diff --git a/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx b/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx index 6780792efb7..ed4128e81f6 100644 --- a/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx +++ b/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx @@ -43,7 +43,8 @@ export const IdeTrustChangeDialog = ({ reason }: IdeTrustChangeDialogProps) => { return ( - {message} Press 'r' to restart Gemini to apply the changes. + {message} Press 'r' to restart Qwen Code and apply the + changes. ); diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index cd8cdcc0e79..f944fd002e6 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -221,7 +221,7 @@ describe('InputPrompt', () => { mockedUseUIState.mockReturnValue({ isFeedbackDialogOpen: false, messageQueue: [], - pendingGeminiHistoryItems: [], + pendingLlmHistoryItems: [], } as unknown as ReturnType); mockedUseUIActions.mockReturnValue({ handleRetryLastPrompt: vi.fn(), @@ -640,7 +640,7 @@ describe('InputPrompt', () => { mockedUseUIState.mockReturnValue({ isFeedbackDialogOpen: true, messageQueue: [], - pendingGeminiHistoryItems: [], + pendingLlmHistoryItems: [], } as unknown as ReturnType); const { stdin, unmount } = renderWithProviders(); @@ -667,7 +667,7 @@ describe('InputPrompt', () => { mockedUseUIState.mockReturnValue({ isFeedbackDialogOpen: false, messageQueue: [], - pendingGeminiHistoryItems: [], + pendingLlmHistoryItems: [], historyManager: { addItem }, } as unknown as ReturnType); vi.mocked(createVoiceRecorder).mockReturnValue({ @@ -1756,7 +1756,7 @@ describe('InputPrompt', () => { mockedUseUIState.mockReturnValue({ isFeedbackDialogOpen: false, messageQueue: [], - pendingGeminiHistoryItems: [], + pendingLlmHistoryItems: [], historyManager: { addItem }, } as unknown as ReturnType); vi.mocked(clipboardUtils.clipboardHasImage).mockImplementation( @@ -5485,7 +5485,7 @@ describe('InputPrompt', () => { mockedUseUIState.mockReturnValue({ isFeedbackDialogOpen: false, messageQueue: [], - pendingGeminiHistoryItems: [ + pendingLlmHistoryItems: [ { type: 'tool_group', tools: [ @@ -5988,7 +5988,7 @@ describe('InputPrompt', () => { mockedUseUIState.mockReturnValue({ isFeedbackDialogOpen: false, messageQueue: ['queued message'], - pendingGeminiHistoryItems: [], + pendingLlmHistoryItems: [], streamingState: StreamingState.Responding, } as unknown as ReturnType); const mockPopAllQueued = vi.fn(() => null); @@ -6019,7 +6019,7 @@ describe('InputPrompt', () => { mockedUseUIState.mockReturnValue({ isFeedbackDialogOpen: false, messageQueue: [], - pendingGeminiHistoryItems: [], + pendingLlmHistoryItems: [], streamingState: StreamingState.Responding, } as unknown as ReturnType); @@ -6046,7 +6046,7 @@ describe('InputPrompt', () => { mockedUseUIState.mockReturnValue({ isFeedbackDialogOpen: false, messageQueue: [], - pendingGeminiHistoryItems: [], + pendingLlmHistoryItems: [], streamingState: StreamingState.Responding, } as unknown as ReturnType); const mockPopAllQueued = vi.fn(() => null); @@ -6081,7 +6081,7 @@ describe('InputPrompt', () => { mockedUseUIState.mockReturnValue({ isFeedbackDialogOpen: false, messageQueue: ['queued follow-up'], - pendingGeminiHistoryItems: [], + pendingLlmHistoryItems: [], streamingState: StreamingState.Responding, } as unknown as ReturnType); const mockPopAllQueued = vi.fn(() => null); @@ -6115,7 +6115,7 @@ describe('InputPrompt', () => { mockedUseUIState.mockReturnValue({ isFeedbackDialogOpen: false, messageQueue: [], - pendingGeminiHistoryItems: [], + pendingLlmHistoryItems: [], streamingState: StreamingState.Responding, } as unknown as ReturnType); props.buffer.setText('draft to clear'); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index e7cd3b0e169..5b3aedf93b1 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -304,12 +304,12 @@ export const InputPrompt: React.FC = ({ const hasActiveToolConfirmation = useMemo( () => Boolean(uiState.confirmationRequest) || - (uiState.pendingGeminiHistoryItems ?? []).some( + (uiState.pendingLlmHistoryItems ?? []).some( (item) => item.type === 'tool_group' && item.tools.some((tool) => tool.confirmationDetails), ), - [uiState.confirmationRequest, uiState.pendingGeminiHistoryItems], + [uiState.confirmationRequest, uiState.pendingLlmHistoryItems], ); const [historyRestoredText, setHistoryRestoredText] = useState( null, diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index a4f883d1495..4f616334c4c 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -175,7 +175,7 @@ const createUIState = (overrides: Partial = {}): UIState => memoryFileCount: 0, streamingState: {} as UIState['streamingState'], initError: null, - pendingGeminiHistoryItems: [], + pendingLlmHistoryItems: [], thought: null, shellModeActive: false, userMessages: [], @@ -1364,7 +1364,7 @@ describe('', () => { expect(historyItemDisplayPropsSpy.mock.calls.at(-1)?.[0]).toEqual( expect.objectContaining({ availableTerminalHeight: 100, - availableTerminalHeightGemini: 65536, + availableTerminalHeightLlm: 65536, }), ); @@ -1394,7 +1394,7 @@ describe('', () => { expect(historyItemDisplayPropsSpy.mock.calls.at(-1)?.[0]).toEqual( expect.objectContaining({ availableTerminalHeight: undefined, - availableTerminalHeightGemini: undefined, + availableTerminalHeightLlm: undefined, }), ); }); diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 45279aa35f3..1234ecda887 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -42,8 +42,8 @@ import { import { TextSelectionController } from '../selection/use-text-selection.js'; import { measureElementPosition } from '../utils/measure-element-position.js'; -// Limit Gemini messages to a very high number of lines to mitigate performance -// issues in the worst case if we somehow get an enormous response from Gemini. +// Limit LLM messages to a very high number of lines to mitigate performance +// issues in the worst case if we somehow get an enormous model response. // This threshold is arbitrary but should be high enough to never impact normal // usage. const MAX_GEMINI_MESSAGE_LINES = 65536; @@ -463,7 +463,7 @@ export const MainContent = ({ footerRef }: MainContentProps) => { availableTerminalHeight={ uiState.constrainHeight ? staticAreaMaxItemHeight : undefined } - availableTerminalHeightGemini={ + availableTerminalHeightLlm={ uiState.constrainHeight ? MAX_GEMINI_MESSAGE_LINES : undefined } item={item} @@ -554,7 +554,7 @@ export const MainContent = ({ footerRef }: MainContentProps) => { terminalWidth={terminalWidth} mainAreaWidth={mainAreaWidth} availableTerminalHeight={staticAreaMaxItemHeight} - availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES} + availableTerminalHeightLlm={MAX_GEMINI_MESSAGE_LINES} key={h.id} item={h} isPending={false} diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx index 413418a145f..d473cf57e30 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx @@ -1473,7 +1473,7 @@ describe('BackgroundTasksDialog', () => { it('renders the Error block on failed status with a "+ Stopped because" verb', () => { // Dream failures need to surface — they are the user's only signal // that consolidation didn't happen as expected (success path - // already produces a memory_saved toast in useGeminiStream). + // already produces a memory_saved toast in useLlmStream). const h = setup([ dreamEntry({ status: 'failed', diff --git a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx index 53ce7c54116..0670bcb7015 100644 --- a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx @@ -139,9 +139,9 @@ export const AuthenticateStep: React.FC = ({ } // Update the client with the new tools - const geminiClient = config.getGeminiClient(); - if (geminiClient) { - await geminiClient.setTools(); + const llmClient = config.getLlmClient(); + if (llmClient) { + await llmClient.setTools(); } setMessages((prev) => [ diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index c544fbd66d6..49568b4718f 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -100,7 +100,7 @@ export interface UIState { memoryFileCount: number; streamingState: StreamingState; initError: string | null; - pendingGeminiHistoryItems: HistoryItemWithoutId[]; + pendingLlmHistoryItems: HistoryItemWithoutId[]; thought: ThoughtSummary | null; shellModeActive: boolean; userMessages: string[]; diff --git a/packages/cli/src/ui/handleAutoUpdate.test.ts b/packages/cli/src/ui/handleAutoUpdate.test.ts index 78a21cc258e..f7dd7c7edcb 100644 --- a/packages/cli/src/ui/handleAutoUpdate.test.ts +++ b/packages/cli/src/ui/handleAutoUpdate.test.ts @@ -116,7 +116,7 @@ describe('handleAutoUpdate', () => { }); it('should show manual update message when enableAutoUpdate is false', () => { - // When enableAutoUpdate is false, gemini.tsx won't call checkForUpdates(), + // When enableAutoUpdate is false, llm.tsx won't call checkForUpdates(), // but if handleAutoUpdate is still called, it should show a manual update message. mockSettings.merged.general!.enableAutoUpdate = false; mockGetInstallationInfo.mockReturnValue({ diff --git a/packages/cli/src/ui/handleAutoUpdate.ts b/packages/cli/src/ui/handleAutoUpdate.ts index 94dc75c3857..b813df774f0 100644 --- a/packages/cli/src/ui/handleAutoUpdate.ts +++ b/packages/cli/src/ui/handleAutoUpdate.ts @@ -40,7 +40,7 @@ export async function handleAutoUpdate( return; } - // enableAutoUpdate is checked in gemini.tsx before calling this function, + // enableAutoUpdate is checked in llm.tsx before calling this function, // so if we get here, auto-update is enabled (or undefined, which defaults to enabled). const isAutoUpdateEnabled = settings.merged.general?.enableAutoUpdate !== false; diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts index fc3c88d24ef..29578fe8b69 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts @@ -38,7 +38,7 @@ import { import { MAX_RETAINED_TOOL_RESULT_DISPLAY_CHARS, type Config, - type GeminiClient, + type LlmClient, type ShellExecutionResult, type ShellOutputEvent, } from '@qwen-code/qwen-code-core'; @@ -54,7 +54,7 @@ describe('useShellCommandProcessor', () => { let onExecMock: Mock; let onDebugMessageMock: Mock; let mockConfig: Config; - let mockGeminiClient: GeminiClient; + let mockLlmClient: LlmClient; let mockShellOutputCallback: (event: ShellOutputEvent) => void; let resolveExecutionPromise: (result: ShellExecutionResult) => void; @@ -77,7 +77,7 @@ describe('useShellCommandProcessor', () => { terminalWidth: 80, }), } as Config; - mockGeminiClient = { addHistory: vi.fn() } as unknown as GeminiClient; + mockLlmClient = { addHistory: vi.fn() } as unknown as LlmClient; vi.mocked(os.platform).mockReturnValue('linux'); vi.mocked(os.tmpdir).mockReturnValue('/tmp'); @@ -106,7 +106,7 @@ describe('useShellCommandProcessor', () => { onExecMock, onDebugMessageMock, mockConfig, - mockGeminiClient, + mockLlmClient, setShellInputFocusedMock, ), ); @@ -187,7 +187,7 @@ describe('useShellCommandProcessor', () => { ], }), ); - expect(mockGeminiClient.addHistory).toHaveBeenCalled(); + expect(mockLlmClient.addHistory).toHaveBeenCalled(); expect(setShellInputFocusedMock).toHaveBeenCalledWith(false); }); @@ -218,7 +218,7 @@ describe('useShellCommandProcessor', () => { expect(finalDisplay).toContain('truncated from'); const modelHistoryText = ( - vi.mocked(mockGeminiClient.addHistory).mock.calls[0]![0].parts![0]! as { + vi.mocked(mockLlmClient.addHistory).mock.calls[0]![0].parts![0]! as { text: string; } ).text; @@ -247,7 +247,7 @@ describe('useShellCommandProcessor', () => { await act(async () => await execPromise); const modelHistoryText = ( - vi.mocked(mockGeminiClient.addHistory).mock.calls[0]![0].parts![0]! as { + vi.mocked(mockLlmClient.addHistory).mock.calls[0]![0].parts![0]! as { text: string; } ).text; diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.ts index beeb92f4dba..c4aff076aa2 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.ts @@ -13,7 +13,7 @@ import { useCallback, useState } from 'react'; import type { AnsiOutput, Config, - GeminiClient, + LlmClient, ShellExecutionResult, } from '@qwen-code/qwen-code-core'; import { @@ -41,8 +41,8 @@ function copyString(value: string): string { return value.split('').join(''); } -function addShellCommandToGeminiHistory( - geminiClient: GeminiClient, +function addShellCommandToLlmHistory( + llmClient: LlmClient, rawQuery: string, resultText: string, ) { @@ -52,7 +52,7 @@ function addShellCommandToGeminiHistory( '\n... (truncated)' : resultText; - geminiClient.addHistory({ + llmClient.addHistory({ role: 'user', parts: [ { @@ -82,7 +82,7 @@ export const useShellCommandProcessor = ( onExec: (command: Promise) => void, onDebugMessage: (message: string) => void, config: Config, - geminiClient: GeminiClient, + llmClient: LlmClient, setShellInputFocused: (value: boolean) => void, terminalWidth?: number, terminalHeight?: number, @@ -322,11 +322,7 @@ export const useShellCommandProcessor = ( ); // Keep the existing LLM history behavior unchanged. - addShellCommandToGeminiHistory( - geminiClient, - rawQuery, - finalOutput, - ); + addShellCommandToLlmHistory(llmClient, rawQuery, finalOutput); }) .catch((err) => { setPendingHistoryItem(null); @@ -384,7 +380,7 @@ export const useShellCommandProcessor = ( addItemToHistory, setPendingHistoryItem, onExec, - geminiClient, + llmClient, setShellInputFocused, terminalHeight, terminalWidth, diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index ba1bbc52d37..36855169d95 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -27,7 +27,7 @@ import { McpPromptLoader } from '../../services/McpPromptLoader.js'; import { ExtensionRefreshState } from '../../config/extension-refresh-state.js'; import { refreshExtensionContentRuntime } from '../../config/extension-runtime-reload.js'; import { - type GeminiClient, + type LlmClient, SlashCommandStatus, ToolConfirmationOutcome, makeFakeConfig, @@ -1406,8 +1406,8 @@ describe('useSlashCommandProcessor', () => { it('should handle "load_history" action', async () => { const mockClient = { setHistory: vi.fn(), - } as unknown as GeminiClient; - vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient); + } as unknown as LlmClient; + vi.spyOn(mockConfig, 'getLlmClient').mockReturnValue(mockClient); const command = createTestCommand({ name: 'load', @@ -1434,8 +1434,8 @@ describe('useSlashCommandProcessor', () => { it('should preserve thoughts when handling "load_history" action', async () => { const mockClient = { setHistory: vi.fn(), - } as unknown as GeminiClient; - vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient); + } as unknown as LlmClient; + vi.spyOn(mockConfig, 'getLlmClient').mockReturnValue(mockClient); const historyWithThoughts = [ { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 65d43800e04..445e3f5e149 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -1314,7 +1314,7 @@ export const useSlashCommandProcessor = ( } } case 'load_history': { - config?.getGeminiClient()?.setHistory(result.clientHistory); + config?.getLlmClient()?.setHistory(result.clientHistory); fullCommandContext.ui.clear(); result.history.forEach((item, index) => { fullCommandContext.ui.addItem(item, index); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/use-llm-stream.test.tsx similarity index 95% rename from packages/cli/src/ui/hooks/useGeminiStream.test.tsx rename to packages/cli/src/ui/hooks/use-llm-stream.test.tsx index 13a6a55efdf..c3a11502872 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/use-llm-stream.test.tsx @@ -8,7 +8,7 @@ import type { Mock, MockInstance } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; -import { useGeminiStream } from './useGeminiStream.js'; +import { useLlmStream } from './use-llm-stream.js'; import * as atCommandProcessor from './atCommandProcessor.js'; import type { TrackedToolCall, @@ -21,7 +21,7 @@ import { useReactToolScheduler } from './useReactToolScheduler.js'; import type { Config, EditorType, - GeminiClient, + LlmClient, AnyToolInvocation, GoalTurnPermit, SteerInput, @@ -30,7 +30,7 @@ import { ApprovalMode, AUTONOMOUS_SENTINEL_DYNAMIC, AuthType, - GeminiEventType as ServerGeminiEventType, + LlmEventType as ServerLlmEventType, MessageSenderType, SendMessageType, ToolErrorType, @@ -58,7 +58,7 @@ const mockSendMessageStream = vi const mockStartChat = vi.fn(); const mockRunVisionBridge = vi.hoisted(() => vi.fn()); -const MockedGeminiClientClass = vi.hoisted(() => +const MockedLlmClientClass = vi.hoisted(() => vi.fn().mockImplementation(function (this: any, _config: any) { // _config this.startChat = mockStartChat; @@ -142,7 +142,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { ); return { ...actualCoreModule, - GeminiClient: MockedGeminiClientClass, + LlmClient: MockedLlmClientClass, UserPromptEvent: MockedUserPromptEvent, ApiCancelEvent: MockedApiCancelEvent, parseAndFormatApiError: mockParseAndFormatApiError, @@ -215,8 +215,8 @@ vi.mock('./slashCommandProcessor.js', () => ({ // --- END MOCKS --- -// --- Tests for useGeminiStream Hook --- -describe('useGeminiStream', () => { +// --- Tests for useLlmStream Hook --- +describe('useLlmStream', () => { let mockAddItem: Mock; let mockConfig: Config; let mockOnDebugMessage: Mock; @@ -247,11 +247,11 @@ describe('useGeminiStream', () => { // (used by lastTurnUserItemRef's identity check). let nextItemId = 1000; mockAddItem = vi.fn(() => nextItemId++); - // Define the mock for getGeminiClient - const mockGetGeminiClient = vi.fn().mockImplementation(() => { - // MockedGeminiClientClass is defined in the module scope by the previous change. + // Define the mock for getLlmClient + const mockGetLlmClient = vi.fn().mockImplementation(() => { + // MockedLlmClientClass is defined in the module scope by the previous change. // It will use the mockStartChat and mockSendMessageStream that are managed within beforeEach. - const clientInstance = new MockedGeminiClientClass(mockConfig); + const clientInstance = new MockedLlmClientClass(mockConfig); return clientInstance; }); @@ -296,7 +296,7 @@ describe('useGeminiStream', () => { ), getProjectRoot: vi.fn(() => '/test/dir'), getFileCheckpointingEnabled: vi.fn(() => false), - getGeminiClient: mockGetGeminiClient, + getLlmClient: mockGetLlmClient, getApprovalMode: () => ApprovalMode.DEFAULT, getTeamManager: vi.fn(() => null), onTeamManagerChange: vi.fn(), @@ -343,11 +343,11 @@ describe('useGeminiStream', () => { mockMarkToolsAsSubmitted, ]); - // Reset mocks for GeminiClient instance methods (startChat and sendMessageStream) - // The GeminiClient constructor itself is mocked at the module level. + // Reset mocks for LlmClient instance methods (startChat and sendMessageStream) + // The LlmClient constructor itself is mocked at the module level. mockStartChat.mockClear().mockResolvedValue({ sendMessageStream: mockSendMessageStream, - } as unknown as any); // GeminiChat -> any + } as unknown as any); // LlmChat -> any mockSendMessageStream .mockClear() .mockReturnValue((async function* () {})()); @@ -373,11 +373,11 @@ describe('useGeminiStream', () => { const renderTestHook = ( initialToolCalls: TrackedToolCall[] = [], - geminiClient?: any, + llmClient?: any, availableTerminalHeightRef?: { current: number }, - onCancelSubmit: Parameters[15] = () => {}, - logger?: Parameters[20], - goalQueueRef?: Parameters[24], + onCancelSubmit: Parameters[15] = () => {}, + logger?: Parameters[20], + goalQueueRef?: Parameters[24], ) => { let currentToolCalls = initialToolCalls; const setToolCalls = (newToolCalls: TrackedToolCall[]) => { @@ -391,7 +391,7 @@ describe('useGeminiStream', () => { mockMarkToolsAsSubmitted, ]); - const client = geminiClient || mockConfig.getGeminiClient(); + const client = llmClient || mockConfig.getLlmClient(); const { result, rerender } = renderHook( (props: { @@ -411,7 +411,7 @@ describe('useGeminiStream', () => { if (props.toolCalls) { setToolCalls(props.toolCalls); } - return useGeminiStream( + return useLlmStream( props.client, props.history, props.addItem, @@ -846,7 +846,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: toolRequest, }; })(), @@ -982,7 +982,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-skill', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'skill-call', responseParts: [{ text: 'skill loaded' }], @@ -1504,7 +1504,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-1', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'call1', responseParts: [{ text: 'tool 1 response' }], @@ -1532,7 +1532,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-1', }, status: 'executing', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, tool: { name: 'tool2', displayName: 'tool2', @@ -1570,7 +1570,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-2', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'call1', responseParts: toolCall1ResponseParts, @@ -1592,7 +1592,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-2', }, status: 'error', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'call2', responseParts: toolCall2ResponseParts, @@ -1612,8 +1612,8 @@ describe('useGeminiStream', () => { }); renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -1670,7 +1670,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-batch-id', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId, responseParts: [{ text: `${callId} response` }], @@ -1691,8 +1691,8 @@ describe('useGeminiStream', () => { }); renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -1719,7 +1719,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'next-tool', name: 'testTool', args: {} }, }; })(), @@ -1770,7 +1770,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-batch-id', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId, responseParts: [{ text: `${callId} response` }], @@ -1791,8 +1791,8 @@ describe('useGeminiStream', () => { }); renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -1823,7 +1823,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'reused-X', name: 'testTool', @@ -1887,7 +1887,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-batch-id', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId, responseParts: [{ text: `${callId} response` }], @@ -1909,8 +1909,8 @@ describe('useGeminiStream', () => { const renderStream = () => renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -1942,7 +1942,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId, name: 'testTool', args: {} }, }; })(), @@ -1997,7 +1997,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-clear-window', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'window-tool', responseParts: [{ text: 'window-tool response' }], @@ -2039,8 +2039,8 @@ describe('useGeminiStream', () => { ); const { result, rerender } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -2143,7 +2143,7 @@ describe('useGeminiStream', () => { goalContext: permit, }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId, responseParts: [{ text: `${callId} response` }], @@ -2162,8 +2162,8 @@ describe('useGeminiStream', () => { return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted]; }); renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -2229,7 +2229,7 @@ describe('useGeminiStream', () => { ...(goalContext ? { goalContext } : {}), }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId, responseParts: [{ text: `${callId} response` }], @@ -2247,12 +2247,12 @@ describe('useGeminiStream', () => { capturedOnComplete = onComplete; return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted]; }); - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); client.getHistoryFunctionResponseIds = vi .fn() .mockReturnValue(new Set(['deduplicated-tool'])); renderHook(() => - useGeminiStream( + useLlmStream( client, [], mockAddItem, @@ -2346,7 +2346,7 @@ describe('useGeminiStream', () => { ...(goalContext ? { goalContext } : {}), }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId, responseParts: [{ text: `${callId} response` }], @@ -2365,8 +2365,8 @@ describe('useGeminiStream', () => { return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted]; }); renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -2393,7 +2393,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'cont-tool', name: 'testTool', args: {} }, }; })(), @@ -2486,7 +2486,7 @@ describe('useGeminiStream', () => { ...(goalContext ? { goalContext } : {}), }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId, responseParts: [{ text: `${callId} response` }], @@ -2505,8 +2505,8 @@ describe('useGeminiStream', () => { return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted]; }); renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -2533,7 +2533,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'cont-tool', name: 'testTool', args: {} }, }; })(), @@ -2616,11 +2616,11 @@ describe('useGeminiStream', () => { capturedOnComplete = onComplete; return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted]; }); - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'update-goal-1', name: 'update_goal', @@ -2633,7 +2633,7 @@ describe('useGeminiStream', () => { })(), ); const { result } = renderHook(() => - useGeminiStream( + useLlmStream( client, [], mockAddItem, @@ -2685,7 +2685,7 @@ describe('useGeminiStream', () => { goalContext: permit, }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'update-goal-1', responseParts, @@ -2765,11 +2765,11 @@ describe('useGeminiStream', () => { capturedOnComplete = onComplete; return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted]; }); - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'update-goal-error', name: 'update_goal', @@ -2782,7 +2782,7 @@ describe('useGeminiStream', () => { })(), ); const { result } = renderHook(() => - useGeminiStream( + useLlmStream( client, [], mockAddItem, @@ -2825,7 +2825,7 @@ describe('useGeminiStream', () => { goalContext: permit, }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'update-goal-error', responseParts: [ @@ -2867,9 +2867,9 @@ describe('useGeminiStream', () => { const originalOwner = {}; const replacementOwner = {}; mockGetActiveInteractionSpan.mockReturnValue(originalOwner); - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); const { result } = renderHook(() => - useGeminiStream( + useLlmStream( client, [], mockAddItem, @@ -2894,7 +2894,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'replacement-tool', name: 'testTool', @@ -2927,7 +2927,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-replaced', }, status: 'cancelled', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'replacement-tool', responseParts: [{ text: 'cancelled' }], @@ -2988,11 +2988,11 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'done', }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 }, @@ -3001,9 +3001,9 @@ describe('useGeminiStream', () => { })(), ); - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); renderHook(() => - useGeminiStream( + useLlmStream( client, [], mockAddItem, @@ -3038,7 +3038,7 @@ describe('useGeminiStream', () => { goalContext: permit, }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'shell-goal-1', responseParts: [ @@ -3108,11 +3108,11 @@ describe('useGeminiStream', () => { return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted]; }); - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'agent-call', name: 'agent', @@ -3124,7 +3124,7 @@ describe('useGeminiStream', () => { })(), ); const { result } = renderHook(() => - useGeminiStream( + useLlmStream( client, [], mockAddItem, @@ -3167,7 +3167,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-agent', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'agent-call', responseParts, @@ -3229,7 +3229,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-agent-2', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'agent-call-2', responseParts, @@ -3279,7 +3279,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-midturn', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'call1', responseParts: toolCallResponseParts, @@ -3310,8 +3310,8 @@ describe('useGeminiStream', () => { }); renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -3391,8 +3391,8 @@ describe('useGeminiStream', () => { .mockReturnValue([]); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -3459,8 +3459,8 @@ describe('useGeminiStream', () => { const detachedController = new AbortController(); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -3526,8 +3526,8 @@ describe('useGeminiStream', () => { .mockReturnValue([]); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -3588,8 +3588,8 @@ describe('useGeminiStream', () => { .mockReturnValue([]); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -3657,8 +3657,8 @@ describe('useGeminiStream', () => { .mockReturnValue([]); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -3725,8 +3725,8 @@ describe('useGeminiStream', () => { .mockReturnValue([]); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -3775,8 +3775,8 @@ describe('useGeminiStream', () => { .mockReturnValue([]); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -3849,8 +3849,8 @@ describe('useGeminiStream', () => { .mockReturnValue([]); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -3961,7 +3961,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-midturn-image', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'call1', responseParts: toolCallResponseParts, @@ -3992,8 +3992,8 @@ describe('useGeminiStream', () => { }); renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -4133,7 +4133,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-midturn-bridge-fail', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'call1', responseParts: toolCallResponseParts, @@ -4164,8 +4164,8 @@ describe('useGeminiStream', () => { }); renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -4253,7 +4253,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-midturn-at-error', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'call1', responseParts: toolCallResponseParts, @@ -4284,8 +4284,8 @@ describe('useGeminiStream', () => { }); renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -4376,7 +4376,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-midturn-at-throw', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'call1', responseParts: toolCallResponseParts, @@ -4407,8 +4407,8 @@ describe('useGeminiStream', () => { }); renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -4504,7 +4504,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-midturn-timeout', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'call1', responseParts: toolCallResponseParts, @@ -4535,8 +4535,8 @@ describe('useGeminiStream', () => { }); renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -4617,7 +4617,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-midturn-cancel', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'call1', responseParts: toolCallResponseParts, @@ -4652,8 +4652,8 @@ describe('useGeminiStream', () => { }); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -4727,7 +4727,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-midturn-cancel-timeout', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'call1', responseParts: toolCallResponseParts, @@ -4762,8 +4762,8 @@ describe('useGeminiStream', () => { }); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -4834,7 +4834,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-midturn', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'call1', responseParts: toolCallResponseParts, @@ -4865,8 +4865,8 @@ describe('useGeminiStream', () => { }); renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -4927,7 +4927,7 @@ describe('useGeminiStream', () => { responseParts: [{ text: 'cancelled' }], errorType: undefined, // FIX: Added missing property }, - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, tool: { displayName: 'mock tool', }, @@ -4936,11 +4936,11 @@ describe('useGeminiStream', () => { } as unknown as AnyToolInvocation, } as TrackedCancelledToolCall, ]; - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: '1', name: 'testTool', @@ -4963,7 +4963,7 @@ describe('useGeminiStream', () => { }); const { result } = renderHook(() => - useGeminiStream( + useLlmStream( client, [], mockAddItem, @@ -5044,7 +5044,7 @@ describe('useGeminiStream', () => { error: undefined, errorType: undefined, // FIX: Added missing property }, - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, }; const cancelledToolCall2: TrackedCancelledToolCall = { request: { @@ -5073,10 +5073,10 @@ describe('useGeminiStream', () => { error: undefined, errorType: undefined, // FIX: Added missing property }, - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, }; const allCancelledTools = [cancelledToolCall1, cancelledToolCall2]; - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); let capturedOnComplete: | ((completedTools: TrackedToolCall[]) => Promise) @@ -5088,7 +5088,7 @@ describe('useGeminiStream', () => { }); renderHook(() => - useGeminiStream( + useLlmStream( client, [], mockAddItem, @@ -5155,7 +5155,7 @@ describe('useGeminiStream', () => { // halts the turn. The TUI must NOT execute them — it should halt // cleanly like the non-interactive runner. yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'rep-1', name: 'run_shell_command', @@ -5165,7 +5165,7 @@ describe('useGeminiStream', () => { }, }; yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'rep-2', name: 'run_shell_command', @@ -5174,13 +5174,13 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-loop-halt', }, }; - yield { type: ServerGeminiEventType.LoopDetected }; + yield { type: ServerLlmEventType.LoopDetected }; })(), ); - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); const { result } = renderHook(() => - useGeminiStream( + useLlmStream( client, [], mockAddItem, @@ -5244,7 +5244,7 @@ describe('useGeminiStream', () => { .mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'tool-dup', providerCallId: 'tool-dup', @@ -5255,7 +5255,7 @@ describe('useGeminiStream', () => { }, }; yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'tool-dup', providerCallId: 'tool-dup', @@ -5273,19 +5273,19 @@ describe('useGeminiStream', () => { .mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'done', }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, }; })(), ); - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); const { result } = renderHook(() => - useGeminiStream( + useLlmStream( client, [], mockAddItem, @@ -5335,7 +5335,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-tui-dup', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'tool-dup', responseParts: [ @@ -5404,7 +5404,7 @@ describe('useGeminiStream', () => { }); it('submits a synthetic response for history-paired duplicate provider ids without scheduling', async () => { - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); client.getHistoryToolCallFingerprints = vi .fn() .mockReturnValue( @@ -5420,7 +5420,7 @@ describe('useGeminiStream', () => { .mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'tool-history', providerCallId: 'tool-history', @@ -5435,7 +5435,7 @@ describe('useGeminiStream', () => { .mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, }; })(), @@ -5458,7 +5458,7 @@ describe('useGeminiStream', () => { }); it('schedules an id-colliding tool call whose args differ from the handled call', async () => { - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); client.getHistoryToolCallFingerprints = vi .fn() .mockReturnValue( @@ -5473,7 +5473,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'tool-history__qwen_dup_2', providerCallId: 'tool-history', @@ -5506,7 +5506,7 @@ describe('useGeminiStream', () => { }); it('drops repeated history-paired duplicate provider ids after the first synthetic response', async () => { - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); client.getHistoryToolCallFingerprints = vi .fn() .mockReturnValue( @@ -5522,7 +5522,7 @@ describe('useGeminiStream', () => { .mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'tool-history', providerCallId: 'tool-history', @@ -5537,7 +5537,7 @@ describe('useGeminiStream', () => { .mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'tool-history', providerCallId: 'tool-history', @@ -5550,7 +5550,7 @@ describe('useGeminiStream', () => { }, }; yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'tool-fresh', providerCallId: 'tool-fresh', @@ -5561,7 +5561,7 @@ describe('useGeminiStream', () => { }, }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, }; })(), @@ -5592,7 +5592,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'generated-1', name: 'shell', @@ -5602,7 +5602,7 @@ describe('useGeminiStream', () => { }, }; yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'generated-2', name: 'shell', @@ -5646,7 +5646,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-race-a', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'call_race_A', responseParts: [ @@ -5673,7 +5673,7 @@ describe('useGeminiStream', () => { } as unknown as AnyToolInvocation, } as unknown as TrackedCompletedToolCall; - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); // Simulate the chat-internal repair pass having already planted a // synthetic functionResponse for the same callId on the previous // (Retry) push. The dedup dispatcher consults @@ -5724,7 +5724,7 @@ describe('useGeminiStream', () => { }); renderHook(() => - useGeminiStream( + useLlmStream( client, [], mockAddItem, @@ -5762,7 +5762,7 @@ describe('useGeminiStream', () => { // The deduped tool DID run locally — `recordCompletedToolCall` must // still fire so toolCallCount / skillsModifiedInSession reflect it, // even though the wire-side submission is dropped. Regression guard: - // an earlier version filtered deduped tools out of `geminiTools` + // an earlier version filtered deduped tools out of `llmTools` // without recording, skipping the metric increment. expect(client.recordCompletedToolCall).toHaveBeenCalledWith('read_file', { path: '/tmp/x.txt', @@ -5791,7 +5791,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-dedup-cancel', }, status: 'cancelled', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'call_dedup_cancelled', responseParts: [ @@ -5818,11 +5818,11 @@ describe('useGeminiStream', () => { } as unknown as AnyToolInvocation, } as unknown as TrackedCancelledToolCall; - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); // Pre-paired in history: dedup will fire for this callId. Wire // the fast-path accessor so the dispatcher takes the // `getHistoryFunctionResponseIds` branch (matches production - // path; see the default mock comment in MockedGeminiClientClass). + // path; see the default mock comment in MockedLlmClientClass). client.getHistoryFunctionResponseIds = vi .fn() .mockReturnValue(new Set(['call_dedup_cancelled'])); @@ -5863,7 +5863,7 @@ describe('useGeminiStream', () => { }); renderHook(() => - useGeminiStream( + useLlmStream( client, [], mockAddItem, @@ -5924,7 +5924,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-race-a-responding', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'call_race_A_responding', responseParts: [ @@ -5951,10 +5951,10 @@ describe('useGeminiStream', () => { } as unknown as AnyToolInvocation, } as unknown as TrackedCompletedToolCall; - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); // Wire the fast-path accessor so the dispatcher takes the // `getHistoryFunctionResponseIds` branch (matches production - // path; see the default mock comment in MockedGeminiClientClass). + // path; see the default mock comment in MockedLlmClientClass). client.getHistoryFunctionResponseIds = vi .fn() .mockReturnValue(new Set(['call_race_A_responding'])); @@ -6011,7 +6011,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue(heldStream); const { result } = renderHook(() => - useGeminiStream( + useLlmStream( client, [], mockAddItem, @@ -6088,7 +6088,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-fast-after-stream', }, status: 'error', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'call_fast_after_stream', responseParts, @@ -6107,7 +6107,7 @@ describe('useGeminiStream', () => { } as unknown as AnyToolInvocation, } as unknown as TrackedCompletedToolCall; - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); let capturedOnComplete: | ((completedTools: TrackedToolCall[]) => Promise) | null = null; @@ -6127,7 +6127,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue(heldStream); const { result } = renderHook(() => - useGeminiStream( + useLlmStream( client, [], mockAddItem, @@ -6211,7 +6211,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-fast-after-cancel', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'call_fast_after_cancel', responseParts, @@ -6230,7 +6230,7 @@ describe('useGeminiStream', () => { } as unknown as AnyToolInvocation, } as unknown as TrackedCompletedToolCall; - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); let capturedOnComplete: | ((completedTools: TrackedCompletedToolCall[]) => Promise) | null = null; @@ -6250,7 +6250,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue(heldStream); const { result } = renderHook(() => - useGeminiStream( + useLlmStream( client, [], mockAddItem, @@ -6307,7 +6307,7 @@ describe('useGeminiStream', () => { }); it('handles a mixed batch (one deduped + one non-deduped) without double-counting telemetry', async () => { - // The dedup filter on `geminiTools` (`!historyCallIdsWithResponse.has(callId)`) + // The dedup filter on `llmTools` (`!historyCallIdsWithResponse.has(callId)`) // is the only thing preventing double `recordCompletedToolCall` // for tools whose late real result lands AFTER the orphan-tool_use // repair already planted a synthetic. Existing dedup tests supply @@ -6333,7 +6333,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-mixed', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'call_mixed_deduped', responseParts: [ @@ -6369,7 +6369,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-mixed', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'call_mixed_fresh', responseParts: [ @@ -6396,7 +6396,7 @@ describe('useGeminiStream', () => { } as unknown as AnyToolInvocation, } as unknown as TrackedCompletedToolCall; - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); // Wire BOTH the fast-path accessor (`getHistoryFunctionResponseIds`) // and the legacy `getHistory()` fallback. Wiring the fast path // is the actual point of this test: production code prefers @@ -6455,7 +6455,7 @@ describe('useGeminiStream', () => { }); renderHook(() => - useGeminiStream( + useLlmStream( client, [], mockAddItem, @@ -6496,7 +6496,7 @@ describe('useGeminiStream', () => { // (b) recordCompletedToolCall fires EXACTLY once per tool (deduped // gets one call from the dedup-loop; fresh gets one from the - // geminiTools loop). The filter is what prevents the double + // llmTools loop). The filter is what prevents the double // record on the deduped callId. const recordedCallIds = ( client.recordCompletedToolCall as unknown as ReturnType @@ -6536,7 +6536,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-4', }, status: 'executing', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, tool: { name: 'tool1', displayName: 'tool1', @@ -6581,8 +6581,8 @@ describe('useGeminiStream', () => { }); const { result, rerender } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -6662,7 +6662,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-1', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, tool: { name, displayName: name, @@ -6720,8 +6720,8 @@ describe('useGeminiStream', () => { ]; renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(config), + useLlmStream( + new MockedLlmClientClass(config), historyWithToolGroup, mockAddItem, config, @@ -6755,7 +6755,7 @@ describe('useGeminiStream', () => { ...mockConfig, getEmitToolUseSummaries: vi.fn(() => false), getFastModel: vi.fn(() => 'qwen-fast'), - getGeminiClient: vi.fn(() => ({ + getLlmClient: vi.fn(() => ({ generateContent: vi.fn(), })), } as unknown as Config; @@ -6779,7 +6779,7 @@ describe('useGeminiStream', () => { ...mockConfig, getEmitToolUseSummaries: vi.fn(() => true), getFastModel: vi.fn(() => undefined), - getGeminiClient: vi.fn(() => ({})), + getLlmClient: vi.fn(() => ({})), getBaseLlmClient: vi.fn(() => ({ generateText })), } as unknown as Config; @@ -6800,7 +6800,7 @@ describe('useGeminiStream', () => { getEmitToolUseSummaries: vi.fn(() => true), getFastModel: vi.fn(() => 'qwen-fast'), getModel: vi.fn(() => 'qwen-main'), - getGeminiClient: vi.fn(() => ({})), + getLlmClient: vi.fn(() => ({})), getBaseLlmClient: vi.fn(() => ({ generateText })), } as unknown as Config; @@ -6849,7 +6849,7 @@ describe('useGeminiStream', () => { getEmitToolUseSummaries: vi.fn(() => true), getFastModel: vi.fn(() => 'qwen-fast'), getModel: vi.fn(() => 'qwen-main'), - getGeminiClient: vi.fn(() => ({})), + getLlmClient: vi.fn(() => ({})), getBaseLlmClient: vi.fn(() => ({ generateText })), } as unknown as Config; @@ -6902,8 +6902,8 @@ describe('useGeminiStream', () => { ]; renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(config), + useLlmStream( + new MockedLlmClientClass(config), history, mockAddItem, config, @@ -6953,7 +6953,7 @@ describe('useGeminiStream', () => { getEmitToolUseSummaries: vi.fn(() => true), getFastModel: vi.fn(() => 'qwen-fast'), getModel: vi.fn(() => 'qwen-main'), - getGeminiClient: vi.fn(() => ({})), + getLlmClient: vi.fn(() => ({})), getBaseLlmClient: vi.fn(() => ({ generateText })), } as unknown as Config; @@ -6983,11 +6983,11 @@ describe('useGeminiStream', () => { const mockStream = (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'Hel', }; yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'lo', }; await holdStream; @@ -7046,7 +7046,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'beforeafter', parts: [ { text: 'before' }, @@ -7113,7 +7113,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'beforeafter', parts: [ { text: 'before' }, @@ -7122,7 +7122,7 @@ describe('useGeminiStream', () => { ], }; yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: toolCall, }; })(), @@ -7165,7 +7165,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: '\n', parts: [ { inlineData: firstImage }, @@ -7220,7 +7220,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: '', parts: images.map((inlineData) => ({ inlineData })), }; @@ -7269,13 +7269,13 @@ describe('useGeminiStream', () => { (async function* () { for (const inlineData of images) { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: '', parts: [{ inlineData }], }; } yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -7315,21 +7315,21 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: '', parts: failedImages.map((inlineData) => ({ inlineData })), }; yield { - type: ServerGeminiEventType.Retry, + type: ServerLlmEventType.Retry, isContinuation: false, }; yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: '', parts: [{ inlineData: replacementImage }], }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -7366,23 +7366,23 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: '', parts: failedImages.map((inlineData) => ({ inlineData })), }; yield { - type: ServerGeminiEventType.ModelFallback, + type: ServerLlmEventType.ModelFallback, fromModel: 'primary-model', toModel: 'fallback-model', fallbackIndex: 1, }; yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: '', parts: [{ inlineData: replacementImage }], }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -7419,21 +7419,21 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: '', parts: firstOutputImages.map((inlineData) => ({ inlineData })), }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: '', parts: [{ inlineData: nextOutputImage }], }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -7471,7 +7471,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: '', parts: [ { @@ -7525,7 +7525,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: '\n', parts: [{ inlineData: image }, { text: '\n' }], }; @@ -7589,7 +7589,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'beforeafter', parts: [ { text: 'before' }, @@ -7599,11 +7599,11 @@ describe('useGeminiStream', () => { }; await waitForRetry; yield { - type: ServerGeminiEventType.Retry, + type: ServerLlmEventType.Retry, isContinuation: false, }; yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'replacement', }; await holdStream; @@ -7668,7 +7668,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'beforeafter', parts: [ { text: 'before' }, @@ -7678,11 +7678,11 @@ describe('useGeminiStream', () => { }; await waitForRetry; yield { - type: ServerGeminiEventType.Retry, + type: ServerLlmEventType.Retry, isContinuation: true, }; yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: ' continued', }; await holdStream; @@ -7743,7 +7743,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'beforeafter', parts: [ { text: 'before' }, @@ -7753,7 +7753,7 @@ describe('useGeminiStream', () => { }; await waitForFallback; yield { - type: ServerGeminiEventType.ModelFallback, + type: ServerLlmEventType.ModelFallback, fromModel: 'primary-model', toModel: 'fallback-model', fallbackIndex: 1, @@ -7817,7 +7817,7 @@ describe('useGeminiStream', () => { .mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'beforeafter', parts: [ { text: 'before' }, @@ -7831,11 +7831,11 @@ describe('useGeminiStream', () => { .mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'replacement', }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -7882,7 +7882,7 @@ describe('useGeminiStream', () => { .mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'beforeafter', parts: [ { text: 'before' }, @@ -7896,11 +7896,11 @@ describe('useGeminiStream', () => { .mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'next answer', }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -7943,7 +7943,7 @@ describe('useGeminiStream', () => { .mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'beforeafter', parts: [ { text: 'before' }, @@ -7957,11 +7957,11 @@ describe('useGeminiStream', () => { .mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'new answer', }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -8016,12 +8016,12 @@ describe('useGeminiStream', () => { const mockStream = (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: '\n\n', }; await waitForNextChunk; yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: '哈哈', }; await holdStream; @@ -8081,11 +8081,11 @@ describe('useGeminiStream', () => { const mockStream = (async function* () { yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { description: 'Think' }, }; yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { description: 'ing' }, }; await holdStream; @@ -8147,7 +8147,7 @@ describe('useGeminiStream', () => { const mockStream = (async function* () { yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { description: longThought }, }; await holdStream; @@ -8229,7 +8229,7 @@ describe('useGeminiStream', () => { }); const mockStream = (async function* () { yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { description: longThought }, }; await holdStream; @@ -8310,7 +8310,7 @@ describe('useGeminiStream', () => { const mockStream = (async function* () { for (const chunk of chunks) { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: chunk, }; } @@ -8378,7 +8378,7 @@ describe('useGeminiStream', () => { }); const streamContent = async ( - result: { current: ReturnType }, + result: { current: ReturnType }, content: string, ) => { let releaseStream!: () => void; @@ -8391,7 +8391,7 @@ describe('useGeminiStream', () => { } const mockStream = (async function* () { for (const chunk of chunks) { - yield { type: ServerGeminiEventType.Content, value: chunk }; + yield { type: ServerLlmEventType.Content, value: chunk }; } await holdStream; })(); @@ -8410,7 +8410,7 @@ describe('useGeminiStream', () => { return releaseStream; }; - const geminiContentItems = () => + const llmContentItems = () => mockAddItem.mock.calls .map(([item]) => item as HistoryItem) .filter( @@ -8432,7 +8432,7 @@ describe('useGeminiStream', () => { const releaseStream = await streamContent(result, longContent); // Did not hang (we got here) and nothing was committed via the loop. - expect(geminiContentItems().length).toBe(0); + expect(llmContentItems().length).toBe(0); const pendingText = result.current.pendingHistoryItems[0]?.text ?? ''; expect(pendingText.split('\n').length).toBe(25); @@ -8459,7 +8459,7 @@ describe('useGeminiStream', () => { ).join('\n'); const releaseStream = await streamContent(result, content); - expect(geminiContentItems().length).toBe(0); + expect(llmContentItems().length).toBe(0); const pendingText = result.current.pendingHistoryItems[0]?.text ?? ''; expect(pendingText.split('\n').length).toBe(20); @@ -8492,7 +8492,7 @@ describe('useGeminiStream', () => { // Any committed chunk that contains table rows must also contain the // separator (i.e. it is a complete table, not an orphaned tail). - for (const item of geminiContentItems()) { + for (const item of llmContentItems()) { const hasTableRow = item.text .split('\n') .some((l) => /^\s*\|.*\|\s*$/.test(l)); @@ -8538,7 +8538,7 @@ describe('useGeminiStream', () => { // The completed tables committed incrementally rather than stalling. All // three tables must have committed (a partial stall — one commits, the // other two dump together — would leave fewer than three committed items). - const committed = geminiContentItems(); + const committed = llmContentItems(); expect(committed.length).toBeGreaterThanOrEqual(3); for (const marker of ['t1r0', 't2r0', 't3r0']) { expect(committed.some((item) => item.text.includes(marker))).toBe(true); @@ -8597,7 +8597,7 @@ describe('useGeminiStream', () => { // Committed BEFORE finalize (streamContent holds the stream open): early // code lines already landed in rather than waiting to dump. - const committed = geminiContentItems(); + const committed = llmContentItems(); expect(committed.length).toBeGreaterThanOrEqual(2); expect(committed.some((item) => item.text.includes('int v0 = 0;'))).toBe( true, @@ -8648,7 +8648,7 @@ describe('useGeminiStream', () => { // Nothing containing mermaid edges was committed mid-block: no committed // chunk carries a partial diagram. - for (const item of geminiContentItems()) { + for (const item of llmContentItems()) { expect(item.text.includes('-->')).toBe(false); } // The whole diagram source sits in the (bounded-by-render) pending item. @@ -8676,12 +8676,12 @@ describe('useGeminiStream', () => { const mockStream = (async function* () { yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { description: '\n\n' }, }; await waitForNextChunk; yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { description: 'Thinking' }, }; await holdStream; @@ -8743,7 +8743,7 @@ describe('useGeminiStream', () => { const mockStream = (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'Initial', }; await holdStream; @@ -8835,8 +8835,8 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue(mockStream); const { result } = renderHook(() => - useGeminiStream( - mockConfig.getGeminiClient(), + useLlmStream( + mockConfig.getLlmClient(), [], mockAddItem, mockConfig, @@ -8872,7 +8872,7 @@ describe('useGeminiStream', () => { it("attaches the cancelled turn's user prompt to onCancelSubmit info.lastTurnUserItem for normal UserQuery", async () => { // The ownership guard in AppContainer's auto-restore depends on - // useGeminiStream emitting the just-added USER history item via + // useLlmStream emitting the just-added USER history item via // `info.lastTurnUserItem`. The AppContainer tests fabricate this // value — pin the producer side here so a regression that drops // `lastTurnUserItemRef.current = { text: trimmedQuery }` cannot @@ -8885,8 +8885,8 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue(mockStream); const { result } = renderHook(() => - useGeminiStream( - mockConfig.getGeminiClient(), + useLlmStream( + mockConfig.getLlmClient(), [], mockAddItem, mockConfig, @@ -8951,8 +8951,8 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue(mockStream); const { result } = renderHook(() => - useGeminiStream( - mockConfig.getGeminiClient(), + useLlmStream( + mockConfig.getLlmClient(), [], mockAddItem, mockConfig, @@ -8990,9 +8990,9 @@ describe('useGeminiStream', () => { expect(info?.lastTurnUserItem).toBeNull(); }); - it('resets lastTurnUserItem to null when a Retry turn cancels, even though Retry skips prepareQueryForGemini', async () => { + it('resets lastTurnUserItem to null when a Retry turn cancels, even though Retry skips prepareQueryForLlm', async () => { // Retry takes a shortcut at submitQuery's dispatch site that - // bypasses prepareQueryForGemini — and therefore bypasses the + // bypasses prepareQueryForLlm — and therefore bypasses the // ref reset that lives there. The submit-level reset must fire // for every top-level submit so a stale ownership snapshot from // an earlier UserQuery can't ride into the retry's cancel info @@ -9004,15 +9004,15 @@ describe('useGeminiStream', () => { // assert on lastTurnUserItem, not on the content flag.) const heldStream = () => (async function* () { - yield { type: ServerGeminiEventType.Content, value: 'x' }; + yield { type: ServerLlmEventType.Content, value: 'x' }; await new Promise(() => {}); })(); mockSendMessageStream.mockReturnValueOnce(heldStream()); mockSendMessageStream.mockReturnValueOnce(heldStream()); const { result } = renderHook(() => - useGeminiStream( - mockConfig.getGeminiClient(), + useLlmStream( + mockConfig.getLlmClient(), [], mockAddItem, mockConfig, @@ -9054,7 +9054,7 @@ describe('useGeminiStream', () => { text: 'first prompt', }); - // Retry the same prompt. Retry bypasses prepareQueryForGemini's + // Retry the same prompt. Retry bypasses prepareQueryForLlm's // reset, so the submit-level reset at the top of submitQuery is // the only thing that clears the stale ref carried over from the // first turn. @@ -9087,14 +9087,14 @@ describe('useGeminiStream', () => { releaseStream = resolve; }); const mockStream = (async function* () { - yield { type: ServerGeminiEventType.Content, value: 'visible reply' }; + yield { type: ServerLlmEventType.Content, value: 'visible reply' }; await holdStream; })(); mockSendMessageStream.mockReturnValue(mockStream); const { result } = renderHook(() => - useGeminiStream( - mockConfig.getGeminiClient(), + useLlmStream( + mockConfig.getLlmClient(), [], mockAddItem, mockConfig, @@ -9151,8 +9151,8 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue(mockStream); const { result } = renderHook(() => - useGeminiStream( - mockConfig.getGeminiClient(), + useLlmStream( + mockConfig.getLlmClient(), [], mockAddItem, mockConfig, @@ -9200,7 +9200,7 @@ describe('useGeminiStream', () => { }); const mockStream = (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'partial response', }; await holdStream; @@ -9209,8 +9209,8 @@ describe('useGeminiStream', () => { const cancelSubmitSpy = vi.fn(); const { result } = renderHook(() => - useGeminiStream( - mockConfig.getGeminiClient(), + useLlmStream( + mockConfig.getLlmClient(), [], mockAddItem, mockConfig, @@ -9285,8 +9285,8 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue(mockStream); const { result } = renderHook(() => - useGeminiStream( - mockConfig.getGeminiClient(), + useLlmStream( + mockConfig.getLlmClient(), [], mockAddItem, mockConfig, @@ -9514,7 +9514,7 @@ describe('useGeminiStream', () => { { request: { callId: 'call1', name: 'tool1', args: {} }, status: 'executing', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, tool: { name: 'tool1', description: 'desc1', @@ -9594,7 +9594,7 @@ describe('useGeminiStream', () => { { request: scheduledRequest, status: 'executing', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, tool: { displayName: 'Save Memory' }, invocation: { getDescription: () => 'Saving memory', @@ -9781,8 +9781,8 @@ describe('useGeminiStream', () => { }); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -9830,7 +9830,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-skill', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'skill-call', responseParts: [{ text: 'skill loaded' }], @@ -10228,7 +10228,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-6', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'save-mem-call-1', responseParts: [{ text: 'Memory saved' }], @@ -10258,8 +10258,8 @@ describe('useGeminiStream', () => { }); renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -10308,7 +10308,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-memory-write', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'write-memory-call-1', responseParts: [{ text: 'Wrote memory' }], @@ -10337,8 +10337,8 @@ describe('useGeminiStream', () => { }); renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -10396,7 +10396,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-bare-remember', }, status: options.status ?? 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId, responseParts: [{ text: `Ran ${toolName}` }], @@ -10633,7 +10633,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-save-memory', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'save-mem-call-1', responseParts: [{ text: 'Memory saved' }], @@ -10660,7 +10660,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-memory-write', }, status: 'success', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, response: { callId: 'write-memory-call-1', responseParts: [{ text: 'Wrote memory' }], @@ -10689,8 +10689,8 @@ describe('useGeminiStream', () => { }); renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -10766,8 +10766,8 @@ describe('useGeminiStream', () => { } as unknown as Config; const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(testConfig), + useLlmStream( + new MockedLlmClientClass(testConfig), [], mockAddItem, testConfig, @@ -10817,7 +10817,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-1', }, status: 'awaiting_approval', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, confirmationDetails: { onConfirm: mockOnConfirm, onCancel: vi.fn(), @@ -10843,7 +10843,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-1', }, status: 'awaiting_approval', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, confirmationDetails: { onConfirm: mockOnConfirm, onCancel: vi.fn(), @@ -10892,7 +10892,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-1', }, status: 'awaiting_approval', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, confirmationDetails: { onConfirm: mockOnConfirm, onCancel: vi.fn(), @@ -10936,7 +10936,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-1', }, status: 'awaiting_approval', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, confirmationDetails: { onConfirm: mockOnConfirmReplace, onCancel: vi.fn(), @@ -10962,7 +10962,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-1', }, status: 'awaiting_approval', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, confirmationDetails: { onConfirm: mockOnConfirmWrite, onCancel: vi.fn(), @@ -10988,7 +10988,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-1', }, status: 'awaiting_approval', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, confirmationDetails: { onConfirm: mockOnConfirmRead, onCancel: vi.fn(), @@ -11039,7 +11039,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-1', }, status: 'awaiting_approval', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, confirmationDetails: { onConfirm: mockOnConfirm, onCancel: vi.fn(), @@ -11084,7 +11084,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-1', }, status: 'awaiting_approval', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, confirmationDetails: { onConfirm: mockOnConfirmSuccess, onCancel: vi.fn(), @@ -11110,7 +11110,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-1', }, status: 'awaiting_approval', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, confirmationDetails: { onConfirm: mockOnConfirmError, onCancel: vi.fn(), @@ -11151,7 +11151,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-1', }, status: 'awaiting_approval', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, // No confirmationDetails tool: { name: 'replace', @@ -11184,7 +11184,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-1', }, status: 'awaiting_approval', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, confirmationDetails: { onCancel: vi.fn(), message: 'Replace text?', @@ -11225,7 +11225,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-1', }, status: 'awaiting_approval', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, confirmationDetails: { onConfirm: mockOnConfirmAwaiting, onCancel: vi.fn(), @@ -11251,7 +11251,7 @@ describe('useGeminiStream', () => { prompt_id: 'prompt-id-1', }, status: 'executing', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, confirmationDetails: { onConfirm: mockOnConfirmExecuting, onCancel: vi.fn(), @@ -11289,19 +11289,19 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'Hello world', }; yield { - type: ServerGeminiEventType.Citation, + type: ServerLlmEventType.Citation, value: 'Citation text', }; yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: ' more', }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -11344,27 +11344,27 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'Hello world', }; yield { - type: ServerGeminiEventType.Citation, + type: ServerLlmEventType.Citation, value: 'Citation text', }; yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: ' more', }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), ); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -11409,22 +11409,22 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'before compression', }; yield { - type: ServerGeminiEventType.ChatCompressed, + type: ServerLlmEventType.ChatCompressed, value: { originalTokenCount: 100, newTokenCount: 50, }, }; yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'after compression', }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -11471,7 +11471,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'This is a truncated response...', parts: [ { text: 'This is ' }, @@ -11480,15 +11480,15 @@ describe('useGeminiStream', () => { ], }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'MAX_TOKENS', usageMetadata: undefined }, }; })(), ); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -11538,7 +11538,7 @@ describe('useGeminiStream', () => { it.each([ { name: 'maximum-turns notice', - event: { type: ServerGeminiEventType.MaxSessionTurns }, + event: { type: ServerLlmEventType.MaxSessionTurns }, expected: { type: 'info', text: expect.stringContaining('maximum number of turns'), @@ -11547,7 +11547,7 @@ describe('useGeminiStream', () => { { name: 'session-token-limit error', event: { - type: ServerGeminiEventType.SessionTokenLimitExceeded, + type: ServerLlmEventType.SessionTokenLimitExceeded, value: { currentTokens: 200, limit: 100, @@ -11567,7 +11567,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'beforeafter', parts: [ { text: 'before' }, @@ -11607,19 +11607,19 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'Complete response', }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), ); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -11661,11 +11661,11 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'Response with unspecified finish', }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'FINISH_REASON_UNSPECIFIED', usageMetadata: undefined, @@ -11675,8 +11675,8 @@ describe('useGeminiStream', () => { ); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -11777,19 +11777,19 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: `Response for ${reason}`, }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason, usageMetadata: undefined }, }; })(), ); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -11843,8 +11843,8 @@ describe('useGeminiStream', () => { }); const { result } = renderHook(() => - useGeminiStream( - mockConfig.getGeminiClient() as GeminiClient, + useLlmStream( + mockConfig.getLlmClient() as LlmClient, [], mockAddItem, mockConfig, @@ -11908,26 +11908,26 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { subject: 'Previous thought', description: 'Old description', }, }; yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'Some response content', }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), ); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -11969,11 +11969,11 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'New response content', }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -12008,24 +12008,24 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { subject: '', description: 'thinking ' }, }; yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { subject: '', description: 'more' }, }; await holdStream; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), ); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -12075,11 +12075,11 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { subject: '', description: 'thinking' }, }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -12100,11 +12100,11 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { subject: '', description: '\n\n' }, }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -12123,15 +12123,15 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { subject: '', description: 'thinking ' }, }; yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { subject: '', description: 'more' }, }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -12157,14 +12157,14 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { subject: 'Evaluating installation approach', description: 'The', }, }; yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { subject: '', description: ' user mentioned globally installed qwen,', @@ -12172,7 +12172,7 @@ describe('useGeminiStream', () => { }; await holdStream; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -12218,11 +12218,11 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { subject: '', description: 'reasoning about the problem' }, }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -12252,15 +12252,15 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { subject: '', description: 'analyzing the question' }, }; yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'The answer is 42', }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -12302,10 +12302,10 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { subject: '', description: 'deep thinking' }, }; - yield { type: ServerGeminiEventType.UserCancelled }; + yield { type: ServerLlmEventType.UserCancelled }; })(), ); @@ -12340,7 +12340,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'partial', }; throw new Error('stream blew up'); @@ -12366,11 +12366,11 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { subject: '', description: 'thinking before error' }, }; yield { - type: ServerGeminiEventType.Error, + type: ServerLlmEventType.Error, value: { message: 'Something went wrong', retryable: false }, }; })(), @@ -12400,11 +12400,11 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { subject: '', description: 'planning tool usage' }, }; yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'tc1', name: 'read_file', @@ -12414,7 +12414,7 @@ describe('useGeminiStream', () => { }, }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -12447,7 +12447,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { subject: '', description: 'reasoning before retry' }, }; // Wait for the buffered thought to be flushed to state before @@ -12456,15 +12456,15 @@ describe('useGeminiStream', () => { emitRetry = resolve; }); yield { - type: ServerGeminiEventType.Retry, + type: ServerLlmEventType.Retry, isContinuation: false, }; yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'retried response', }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -12521,7 +12521,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Retry, + type: ServerLlmEventType.Retry, retryInfo: { message: '[API Error: Rate limit exceeded]', attempt: 1, @@ -12533,21 +12533,21 @@ describe('useGeminiStream', () => { continueToRetryAttempt = resolve; }); yield { - type: ServerGeminiEventType.Retry, + type: ServerLlmEventType.Retry, }; await new Promise((resolve) => { resolveStream = resolve; }); yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), ); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -12639,7 +12639,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Retry, + type: ServerLlmEventType.Retry, retryInfo: { message: '[API Error: Rate limit exceeded]', attempt: 1, @@ -12651,22 +12651,22 @@ describe('useGeminiStream', () => { continueAfterCountdown = resolve; }); yield { - type: ServerGeminiEventType.Retry, + type: ServerLlmEventType.Retry, }; yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'Success after retry', }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), ); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -12740,7 +12740,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Error, + type: ServerLlmEventType.Error, value: { error: { message: 'Fatal API error' } }, }; })(), @@ -12765,7 +12765,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Error, + type: ServerLlmEventType.Error, value: { error: { message: 'Goal stream error' } }, }; })(), @@ -12805,7 +12805,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Retry, + type: ServerLlmEventType.Retry, retryInfo: { message: '[API Error: Socket closed]', attempt: 1, @@ -12817,11 +12817,11 @@ describe('useGeminiStream', () => { continueAfterCountdown = resolve; }); yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'Recovered content', }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -12883,11 +12883,11 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Error, + type: ServerLlmEventType.Error, value: { error: { message: 'Goal terminal error' } }, }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -12925,11 +12925,11 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Error, + type: ServerLlmEventType.Error, value: { error: { message: 'Goal terminal error' } }, }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -12969,8 +12969,8 @@ describe('useGeminiStream', () => { ]); const { result, rerender } = renderHook(() => - useGeminiStream( - mockConfig.getGeminiClient(), + useLlmStream( + mockConfig.getLlmClient(), [], mockAddItem, mockConfig, @@ -13032,16 +13032,16 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { subject: 'Some thought', description: 'Description' }, }; - yield { type: ServerGeminiEventType.UserCancelled }; + yield { type: ServerLlmEventType.UserCancelled }; })(), ); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -13087,14 +13087,14 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'call_cancelled', name: 'write_file', args: { path: 'cancelled.txt' }, }, }; - yield { type: ServerGeminiEventType.UserCancelled }; + yield { type: ServerLlmEventType.UserCancelled }; })(), ); @@ -13121,7 +13121,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'call_aborted', name: 'write_file', @@ -13159,19 +13159,19 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Thought, + type: ServerLlmEventType.Thought, value: { subject: 'Some thought', description: 'Description' }, }; yield { - type: ServerGeminiEventType.Error, + type: ServerLlmEventType.Error, value: { error: { message: 'Test error' } }, }; })(), ); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -13219,15 +13219,15 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.Error, + type: ServerLlmEventType.Error, value: { error: { message: 'First error' } }, }; })(), ); const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), [], mockAddItem, mockConfig, @@ -13266,7 +13266,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'Success response', }; })(), @@ -13294,7 +13294,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.Error, + type: ServerLlmEventType.Error, value: { error: { message: 'First error' } }, }; })(), @@ -13323,7 +13323,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'Second response', }; })(), @@ -13366,7 +13366,7 @@ describe('useGeminiStream', () => { const firstStream = (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'First call content', }; await firstCallPromise; @@ -13418,7 +13418,7 @@ describe('useGeminiStream', () => { mainAbortSignal = signal; return (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'First call content', }; await firstCallPromise; @@ -13516,7 +13516,7 @@ describe('useGeminiStream', () => { mainAbortSignal = signal; return (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'First call content', }; await firstCallPromise; @@ -13611,7 +13611,7 @@ describe('useGeminiStream', () => { mainPromptId = promptId; return (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'main-tool', name: 'testTool', @@ -13627,7 +13627,7 @@ describe('useGeminiStream', () => { btwPromptId = promptId; return (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'btw-tool', name: 'testTool', @@ -13646,8 +13646,8 @@ describe('useGeminiStream', () => { return (async function* () { await continuationPending; yield signal.aborted - ? { type: ServerGeminiEventType.UserCancelled } - : { type: ServerGeminiEventType.Finished, value: 'STOP' }; + ? { type: ServerLlmEventType.UserCancelled } + : { type: ServerLlmEventType.Finished, value: 'STOP' }; })(); }, ); @@ -13699,7 +13699,7 @@ describe('useGeminiStream', () => { ], errorType: undefined, }, - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, tool: { displayName: 'mock tool' }, invocation: { getDescription: () => 'Mock description', @@ -13780,7 +13780,7 @@ describe('useGeminiStream', () => { return (async function* () { await mainPending; if (signal.aborted) { - yield { type: ServerGeminiEventType.UserCancelled }; + yield { type: ServerLlmEventType.UserCancelled }; } })(); } @@ -13788,7 +13788,7 @@ describe('useGeminiStream', () => { btwPromptId = promptId; return (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'repaired-btw-tool', name: 'testTool', @@ -13859,7 +13859,7 @@ describe('useGeminiStream', () => { ], errorType: undefined, }, - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, tool: { displayName: 'mock tool' }, invocation: { getDescription: () => 'Mock description', @@ -13902,7 +13902,7 @@ describe('useGeminiStream', () => { return (async function* () { await mainPending; if (signal.aborted) { - yield { type: ServerGeminiEventType.UserCancelled }; + yield { type: ServerLlmEventType.UserCancelled }; } })(); } @@ -13911,7 +13911,7 @@ describe('useGeminiStream', () => { return (async function* () { await btwPending; yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'surviving-btw-tool', name: 'testTool', @@ -13923,7 +13923,7 @@ describe('useGeminiStream', () => { })(); } return (async function* () { - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; + yield { type: ServerLlmEventType.Finished, value: 'STOP' }; })(); }); mockScheduleToolCalls.mockImplementation((requests, signal) => { @@ -14000,7 +14000,7 @@ describe('useGeminiStream', () => { prompt_id: btwPromptId, }, status: 'executing', - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, tool: { displayName: 'mock tool' }, invocation: { getDescription: () => 'Mock description', @@ -14064,7 +14064,7 @@ describe('useGeminiStream', () => { ownersByPromptId.set(promptId, mainOwner); return (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'main-tool', name: 'testTool', @@ -14083,7 +14083,7 @@ describe('useGeminiStream', () => { ownersByPromptId.set(promptId, btwOwner); return (async function* () { yield { - type: ServerGeminiEventType.ToolCallRequest, + type: ServerLlmEventType.ToolCallRequest, value: { callId: 'btw-cancelled-tool', name: 'testTool', @@ -14103,9 +14103,9 @@ describe('useGeminiStream', () => { return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted]; }); - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); const { result } = renderHook(() => - useGeminiStream( + useLlmStream( client, [], mockAddItem, @@ -14180,7 +14180,7 @@ describe('useGeminiStream', () => { responseParts: [{ text: 'cancelled' }], errorType: undefined, }, - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, tool: { displayName: 'mock tool' }, invocation: { getDescription: () => 'Mock description', @@ -14351,17 +14351,17 @@ describe('useGeminiStream', () => { if (streamCount === 1) { return (async function* () { await mainStream; - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; + yield { type: ServerLlmEventType.Finished, value: 'STOP' }; })(); } if (JSON.stringify(query).includes('second-tool')) { return (async function* () { await concurrentContinuation; - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; + yield { type: ServerLlmEventType.Finished, value: 'STOP' }; })(); } return (async function* () { - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; + yield { type: ServerLlmEventType.Finished, value: 'STOP' }; })(); }); @@ -14396,7 +14396,7 @@ describe('useGeminiStream', () => { ], errorType: undefined, }, - responseSubmittedToGemini: false, + responseSubmittedToLlm: false, tool: { displayName: 'mock tool' }, invocation: { getDescription: () => 'Mock description', @@ -14415,9 +14415,9 @@ describe('useGeminiStream', () => { ); } - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); const { result } = renderHook(() => - useGeminiStream( + useLlmStream( client, [], mockAddItem, @@ -14590,11 +14590,11 @@ describe('useGeminiStream', () => { await new Promise((resolve) => { resolveStream = resolve; }); - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; + yield { type: ServerLlmEventType.Finished, value: 'STOP' }; })(), ); - const client = new MockedGeminiClientClass(mockConfig); + const client = new MockedLlmClientClass(mockConfig); const { result } = renderTestHook( [], client, @@ -14639,21 +14639,21 @@ describe('useGeminiStream', () => { // Mock a long-running stream for the first call const firstStream = (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'First call content', }; await firstCallPromise; // Wait until we manually resolve - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; + yield { type: ServerLlmEventType.Finished, value: 'STOP' }; })(); // Mock a stream for the second call (should not be used) const secondStream = (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'Second call content', }; await secondCallPromise; - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; + yield { type: ServerLlmEventType.Finished, value: 'STOP' }; })(); let callCount = 0; @@ -14710,19 +14710,19 @@ describe('useGeminiStream', () => { .mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'First response', }; - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; + yield { type: ServerLlmEventType.Finished, value: 'STOP' }; })(), ) .mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'Second response', }; - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; + yield { type: ServerLlmEventType.Finished, value: 'STOP' }; })(), ); @@ -14787,8 +14787,8 @@ describe('useGeminiStream', () => { const mockLoopDetectionService = { disableForSession: vi.fn(), }; - mockConfig.getGeminiClient = vi.fn().mockReturnValue({ - ...new MockedGeminiClientClass(mockConfig), + mockConfig.getLlmClient = vi.fn().mockReturnValue({ + ...new MockedLlmClientClass(mockConfig), getLoopDetectionService: () => mockLoopDetectionService, }); }); @@ -14797,11 +14797,11 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'Some content', }; yield { - type: ServerGeminiEventType.LoopDetected, + type: ServerLlmEventType.LoopDetected, }; })(), ); @@ -14832,15 +14832,15 @@ describe('useGeminiStream', () => { disableForSession: vi.fn(), }; const mockClient = { - ...new MockedGeminiClientClass(mockConfig), + ...new MockedLlmClientClass(mockConfig), getLoopDetectionService: () => mockLoopDetectionService, }; - mockConfig.getGeminiClient = vi.fn().mockReturnValue(mockClient); + mockConfig.getLlmClient = vi.fn().mockReturnValue(mockClient); mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.LoopDetected, + type: ServerLlmEventType.LoopDetected, }; })(), ); @@ -14886,15 +14886,15 @@ describe('useGeminiStream', () => { disableForSession: vi.fn(), }; const mockClient = { - ...new MockedGeminiClientClass(mockConfig), + ...new MockedLlmClientClass(mockConfig), getLoopDetectionService: () => mockLoopDetectionService, }; - mockConfig.getGeminiClient = vi.fn().mockReturnValue(mockClient); + mockConfig.getLlmClient = vi.fn().mockReturnValue(mockClient); mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.LoopDetected, + type: ServerLlmEventType.LoopDetected, }; })(), ); @@ -14940,7 +14940,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.LoopDetected, + type: ServerLlmEventType.LoopDetected, }; })(), ); @@ -14976,7 +14976,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.LoopDetected, + type: ServerLlmEventType.LoopDetected, }; })(), ); @@ -15013,11 +15013,11 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'Some response content', }; yield { - type: ServerGeminiEventType.LoopDetected, + type: ServerLlmEventType.LoopDetected, }; })(), ); @@ -15051,7 +15051,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.UserPromptSubmitBlocked, + type: ServerLlmEventType.UserPromptSubmitBlocked, value: { reason: 'Hook blocked due to security policy', originalPrompt: 'This is the original user prompt', @@ -15085,11 +15085,11 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'Partial response before block', }; yield { - type: ServerGeminiEventType.UserPromptSubmitBlocked, + type: ServerLlmEventType.UserPromptSubmitBlocked, value: { reason: 'Security violation detected', originalPrompt: 'Execute system command', @@ -15142,11 +15142,11 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.ActiveGoal, + type: ServerLlmEventType.ActiveGoal, value: activeGoal, }; yield { - type: ServerGeminiEventType.ActiveGoal, + type: ServerLlmEventType.ActiveGoal, value: null, }; })(), @@ -15178,11 +15178,11 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.ActiveGoal, + type: ServerLlmEventType.ActiveGoal, value: activeGoal, }; yield { - type: ServerGeminiEventType.ActiveGoal, + type: ServerLlmEventType.ActiveGoal, value: null, }; })(), @@ -15206,7 +15206,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.StopHookLoop, + type: ServerLlmEventType.StopHookLoop, value: { iterationCount: 3, reasons: [ @@ -15261,7 +15261,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.StopHookLoop, + type: ServerLlmEventType.StopHookLoop, value: { iterationCount: 2, reasons: ['controlled continuation prompt'], @@ -15294,11 +15294,11 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'Initial response before loop', }; yield { - type: ServerGeminiEventType.StopHookLoop, + type: ServerLlmEventType.StopHookLoop, value: { iterationCount: 5, reasons: ['Hook reason 1', 'Hook reason 2'], @@ -15342,7 +15342,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.StopHookLoop, + type: ServerLlmEventType.StopHookLoop, value: { iterationCount: 1, reasons: ['Single hook execution'], @@ -15379,7 +15379,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'Final Goal output', parts: [ { text: 'Final ' }, @@ -15388,7 +15388,7 @@ describe('useGeminiStream', () => { ], }; yield { - type: ServerGeminiEventType.GoalState, + type: ServerLlmEventType.GoalState, cause: 'complete' as const, value: { v: 2 as const, @@ -15408,11 +15408,11 @@ describe('useGeminiStream', () => { }, }; yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: ' continued', }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }; })(), @@ -15443,7 +15443,7 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.HookSystemMessage, + type: ServerLlmEventType.HookSystemMessage, value: '◐ Ralph iteration 3 | No completion promise set', }; })(), @@ -15472,11 +15472,11 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'Here is the response', }; yield { - type: ServerGeminiEventType.HookSystemMessage, + type: ServerLlmEventType.HookSystemMessage, value: 'Stop hook feedback message', }; })(), @@ -15515,7 +15515,7 @@ describe('useGeminiStream', () => { }); describe('cron scheduler initialization', () => { - // Renders useGeminiStream wired to a provided cron scheduler mock, with a + // Renders useLlmStream wired to a provided cron scheduler mock, with a // controllable isConfigInitialized gate. `config` identity is stable across // rerenders so the cron effect only re-runs when `initialized` flips. const renderCronHook = (scheduler: unknown, initialized: boolean) => { @@ -15526,8 +15526,8 @@ describe('useGeminiStream', () => { } as unknown as Config; return renderHook( (props: { initialized: boolean }) => - useGeminiStream( - new MockedGeminiClientClass(cronConfig), + useLlmStream( + new MockedLlmClientClass(cronConfig), [], mockAddItem, cronConfig, @@ -15663,11 +15663,11 @@ describe('useGeminiStream', () => { mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'Hello world', }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 }, @@ -15690,20 +15690,20 @@ describe('useGeminiStream', () => { (call: any[]) => call[0]?.type === 'gemini', ); expect(geminiCalls.length).toBeGreaterThanOrEqual(1); - const geminiItem = geminiCalls[0][0]; - expect(typeof geminiItem.timestamp).toBe('number'); - expect(geminiItem.timestamp).toBeGreaterThan(0); + const llmItem = geminiCalls[0][0]; + expect(typeof llmItem.timestamp).toBe('number'); + expect(llmItem.timestamp).toBeGreaterThan(0); }); it('does not attach timestamp to non-gemini items', async () => { mockSendMessageStream.mockReturnValueOnce( (async function* () { yield { - type: ServerGeminiEventType.Content, + type: ServerLlmEventType.Content, value: 'response', }; yield { - type: ServerGeminiEventType.Finished, + type: ServerLlmEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 }, @@ -15744,8 +15744,8 @@ describe('useGeminiStream', () => { ]; renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), + useLlmStream( + new MockedLlmClientClass(mockConfig), history, mockAddItem, mockConfig, diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/use-llm-stream.ts similarity index 96% rename from packages/cli/src/ui/hooks/useGeminiStream.ts rename to packages/cli/src/ui/hooks/use-llm-stream.ts index 02d586c1ec2..6f259a39ebe 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/use-llm-stream.ts @@ -16,20 +16,20 @@ import { runOutsideAgentContext, type Config, type EditorType, - type GeminiClient, + type LlmClient, type Logger, type RetryInfo, - type ServerGeminiChatCompressedEvent, - type ServerGeminiContentEvent as ContentEvent, - type ServerGeminiFinishedEvent, - type ServerGeminiStreamEvent as GeminiEvent, + type ServerLlmChatCompressedEvent, + type ServerLlmContentEvent as ContentEvent, + type ServerLlmFinishedEvent, + type ServerLlmStreamEvent as LlmEvent, type ThoughtSummary, type ToolCallRequestInfo, type ToolCallResponseInfo, type LlmErrorEventValue, type GoalTurnPermit, type SteerInput, - GeminiEventType as ServerGeminiEventType, + LlmEventType as ServerLlmEventType, SendMessageType, createDebugLogger, ToolNames, @@ -82,7 +82,7 @@ import type { HistoryItem, HistoryItemWithoutId, HistoryItemToolGroup, - HistoryItemGemini, + HistoryItemLlm, InlineImageData, SlashCommandProcessorResult, } from '../types.js'; @@ -473,11 +473,11 @@ export interface CancelSubmitInfo { } /** - * Manages the Gemini stream, including user input, command processing, + * Manages the LLM stream, including user input, command processing, * API interaction, and tool call lifecycle. */ -export const useGeminiStream = ( - geminiClient: GeminiClient, +export const useLlmStream = ( + llmClient: LlmClient, history: HistoryItem[], addItem: UseHistoryManagerReturn['addItem'], config: Config, @@ -654,9 +654,9 @@ export const useGeminiStream = ( [goalQueueRef], ); const lastPromptRef = useRef(null); - // Records the USER history item that THIS turn's prepareQueryForGemini + // Records the USER history item that THIS turn's prepareQueryForLlm // added (if any). Reset to null at the start of every turn (including - // Retry, which bypasses prepareQueryForGemini). Cron / Notification / + // Retry, which bypasses prepareQueryForLlm). Cron / Notification / // slash submit_prompt paths don't add a user item, so this stays null // on those turns. The cancel handler uses this to verify that the // candidate `lastUserItem` it's about to rewind actually came from the @@ -690,8 +690,8 @@ export const useGeminiStream = ( // (same turn, performance-split continuation) does not. const commitItem = useCallback( (item: HistoryItemWithoutId, userMessageTimestamp: number): number => { - if (item.type === 'gemini' && !(item as HistoryItemGemini).timestamp) { - (item as HistoryItemGemini).timestamp = Date.now(); + if (item.type === 'gemini' && !(item as HistoryItemLlm).timestamp) { + (item as HistoryItemLlm).timestamp = Date.now(); } return addItem(item, userMessageTimestamp); }, @@ -1093,7 +1093,7 @@ export const useGeminiStream = ( onExec, onDebugMessage, config, - geminiClient, + llmClient, setShellInputFocused, terminalWidth, terminalHeight, @@ -1139,7 +1139,7 @@ export const useGeminiStream = ( tc.status === 'error' || tc.status === 'cancelled') && !(tc as TrackedCompletedToolCall | TrackedCancelledToolCall) - .responseSubmittedToGemini), + .responseSubmittedToLlm), ) ) { return StreamingState.Responding; @@ -1399,7 +1399,7 @@ export const useGeminiStream = ( [addItem, config], ); - const prepareQueryForGemini = useCallback( + const prepareQueryForLlm = useCallback( async ( query: PartListUnion, userMessageTimestamp: number, @@ -1428,7 +1428,7 @@ export const useGeminiStream = ( lastTurnUserItemRef.current = null; } - let localQueryToSendToGemini: PartListUnion | null = null; + let localQueryToSendToLlm: PartListUnion | null = null; if (typeof query === 'string') { const trimmedQuery = query.trim(); @@ -1489,7 +1489,7 @@ export const useGeminiStream = ( }; } case 'submit_prompt': { - localQueryToSendToGemini = slashCommandResult.content; + localQueryToSendToLlm = slashCommandResult.content; submitPromptOnCompleteRef.current = slashCommandResult.onComplete ?? null; refreshContextFilesOnWriteRef.current = Boolean( @@ -1524,17 +1524,17 @@ export const useGeminiStream = ( } const bridgeResult = await applyVisionBridgeIfNeeded( - localQueryToSendToGemini, + localQueryToSendToLlm, userMessageTimestamp, abortSignal, ); if (!bridgeResult.shouldProceed) { return { queryToSend: null, shouldProceed: false }; } - localQueryToSendToGemini = bridgeResult.parts; + localQueryToSendToLlm = bridgeResult.parts; return { - queryToSend: localQueryToSendToGemini, + queryToSend: localQueryToSendToLlm, shouldProceed: true, }; } @@ -1554,7 +1554,7 @@ export const useGeminiStream = ( return { queryToSend: null, shouldProceed: false }; } - localQueryToSendToGemini = trimmedQuery; + localQueryToSendToLlm = trimmedQuery; // Cron prompts are already rendered as a `● …` notification by // their queue drain, so skip the user-message history item to @@ -1610,30 +1610,30 @@ export const useGeminiStream = ( if (!atCommandResult.shouldProceed) { return { queryToSend: null, shouldProceed: false }; } - localQueryToSendToGemini = atCommandResult.processedQuery; + localQueryToSendToLlm = atCommandResult.processedQuery; } const bridgeResult = await applyVisionBridgeIfNeeded( - localQueryToSendToGemini, + localQueryToSendToLlm, userMessageTimestamp, abortSignal, ); if (!bridgeResult.shouldProceed) { return { queryToSend: null, shouldProceed: false }; } - localQueryToSendToGemini = bridgeResult.parts; + localQueryToSendToLlm = bridgeResult.parts; } else { // It's a function response (PartListUnion that isn't a string) - localQueryToSendToGemini = query; + localQueryToSendToLlm = query; } - if (localQueryToSendToGemini === null) { + if (localQueryToSendToLlm === null) { onDebugMessage( - 'Query processing resulted in null, not sending to Gemini.', + 'Query processing resulted in null, not sending to the model.', ); return { queryToSend: null, shouldProceed: false }; } - return { queryToSend: localQueryToSendToGemini, shouldProceed: true }; + return { queryToSend: localQueryToSendToLlm, shouldProceed: true }; }, [ config, @@ -1654,7 +1654,7 @@ export const useGeminiStream = ( const handleContentEvent = useCallback( ( eventValue: ContentEvent['value'], - currentGeminiMessageBuffer: string, + currentLlmMessageBuffer: string, userMessageTimestamp: number, startAsContinuation = false, ): string => { @@ -1670,15 +1670,15 @@ export const useGeminiStream = ( // during the pre-cancel flush (their addItem hasn't re-rendered // React history by the time AppContainer's guard runs). turnSawContentEventRef.current = true; - let newGeminiMessageBuffer = currentGeminiMessageBuffer + eventValue; + let newLlmMessageBuffer = currentLlmMessageBuffer + eventValue; const pendingItem = pendingHistoryItemRef.current; if ( (pendingItem?.type === 'gemini' || pendingItem?.type === 'gemini_content') && (pendingItem.images?.length || pendingItem.omittedImageCount) ) { - if (newGeminiMessageBuffer.trim().length === 0) { - return newGeminiMessageBuffer; + if (newLlmMessageBuffer.trim().length === 0) { + return newLlmMessageBuffer; } stagePendingAssistantItem(); } @@ -1686,8 +1686,8 @@ export const useGeminiStream = ( pendingHistoryItemRef.current?.type !== 'gemini' && pendingHistoryItemRef.current?.type !== 'gemini_content' ) { - if (newGeminiMessageBuffer.trim().length === 0) { - return newGeminiMessageBuffer; + if (newLlmMessageBuffer.trim().length === 0) { + return newLlmMessageBuffer; } if (pendingHistoryItemRef.current) { commitItemInOrder( @@ -1700,7 +1700,7 @@ export const useGeminiStream = ( ? { type: 'gemini_content', text: '' } : { type: 'gemini', text: '', timestamp: Date.now() }, ); - newGeminiMessageBuffer = stripLeadingBlankLines(newGeminiMessageBuffer); + newLlmMessageBuffer = stripLeadingBlankLines(newLlmMessageBuffer); } // Split large messages for better rendering performance. Ideally, // we should maximize the amount of output sent to . @@ -1712,17 +1712,17 @@ export const useGeminiStream = ( : startAsContinuation ? 'gemini_content' : 'gemini'; - while (newGeminiMessageBuffer.length > STREAM_PENDING_ITEM_MAX_CHARS) { + while (newLlmMessageBuffer.length > STREAM_PENDING_ITEM_MAX_CHARS) { const splitPoint = findLastSafeSplitPoint( - newGeminiMessageBuffer, + newLlmMessageBuffer, STREAM_PENDING_ITEM_MAX_CHARS, ); const safeSplitPoint = - splitPoint > 0 && splitPoint < newGeminiMessageBuffer.length + splitPoint > 0 && splitPoint < newLlmMessageBuffer.length ? splitPoint : STREAM_PENDING_ITEM_MAX_CHARS; - // This indicates that we need to split up this Gemini Message. + // This indicates that we need to split up this LLM message. // Splitting a message is primarily a performance consideration. There is a // component at the root of App.tsx which takes care of rendering // content statically or dynamically. Everything but the last message is @@ -1733,7 +1733,7 @@ export const useGeminiStream = ( // Repair fences when the split lands inside a code block so the tail // does not render as prose (see splitFencedMarkdown). const { before: beforeText, after: afterText } = splitFencedMarkdown( - newGeminiMessageBuffer, + newLlmMessageBuffer, safeSplitPoint, ); commitItemInOrder( @@ -1744,7 +1744,7 @@ export const useGeminiStream = ( userMessageTimestamp, ); nextPendingType = 'gemini_content'; - newGeminiMessageBuffer = afterText; + newLlmMessageBuffer = afterText; } // Rendered-height-aware incremental commit. Commit whole chunks to // so the pending (live) item's ESTIMATED rendered height stays @@ -1780,7 +1780,7 @@ export const useGeminiStream = ( ); const tableClampRows = Math.max(2, viewportRows - 3); while (true) { - const bufferLines = newGeminiMessageBuffer.split('\n'); + const bufferLines = newLlmMessageBuffer.split('\n'); const { keptLines, clipped } = fitPendingSlice( bufferLines, commitWidth, @@ -1817,13 +1817,10 @@ export const useGeminiStream = ( // gutter), so commit the budget-fit prefix and keep streaming. Restrict // this to code blocks: other tall blocks (tables/lists) must stay whole // and are still kept pending. - const capIndex = charIndexAfterLine( - newGeminiMessageBuffer, - keptLines, - ); + const capIndex = charIndexAfterLine(newLlmMessageBuffer, keptLines); const fenceInfo = capIndex > 0 - ? getEnclosingFenceInfo(newGeminiMessageBuffer, capIndex) + ? getEnclosingFenceInfo(newLlmMessageBuffer, capIndex) : null; // Only hard-split a real code block. Other tall blocks (tables/lists) // must stay whole, and mermaid needs its whole source to render a @@ -1834,20 +1831,17 @@ export const useGeminiStream = ( } target = capIndex; } else { - target = charIndexAfterLine(newGeminiMessageBuffer, boundaryLine + 1); + target = charIndexAfterLine(newLlmMessageBuffer, boundaryLine + 1); if (target <= 0) break; } - const splitPoint = findLastSafeSplitPoint( - newGeminiMessageBuffer, - target, - ); - if (splitPoint <= 0 || splitPoint >= newGeminiMessageBuffer.length) { + const splitPoint = findLastSafeSplitPoint(newLlmMessageBuffer, target); + if (splitPoint <= 0 || splitPoint >= newLlmMessageBuffer.length) { break; } // Repair fences when the split lands inside a code block so the tail // does not render as prose (see splitFencedMarkdown). const { before: beforeText, after: afterText } = splitFencedMarkdown( - newGeminiMessageBuffer, + newLlmMessageBuffer, splitPoint, ); commitItemInOrder( @@ -1858,22 +1852,22 @@ export const useGeminiStream = ( userMessageTimestamp, ); nextPendingType = 'gemini_content'; - newGeminiMessageBuffer = afterText; + newLlmMessageBuffer = afterText; } // Update the existing message with accumulated content. setPendingHistoryItem((item) => { const base: HistoryItemWithoutId = { type: nextPendingType, - text: newGeminiMessageBuffer, + text: newLlmMessageBuffer, }; if (item && 'timestamp' in item) { - (base as HistoryItemGemini).timestamp = ( - item as HistoryItemGemini + (base as HistoryItemLlm).timestamp = ( + item as HistoryItemLlm ).timestamp; } return base; }); - return newGeminiMessageBuffer; + return newLlmMessageBuffer; }, [ commitItemInOrder, @@ -2183,7 +2177,7 @@ export const useGeminiStream = ( ); const handleFinishedEvent = useCallback( - (event: ServerGeminiFinishedEvent, userMessageTimestamp: number) => { + (event: ServerLlmFinishedEvent, userMessageTimestamp: number) => { const finishReason = event.value.reason; if (!finishReason) { return; @@ -2241,7 +2235,7 @@ export const useGeminiStream = ( const handleChatCompressionEvent = useCallback( ( - eventValue: ServerGeminiChatCompressedEvent['value'], + eventValue: ServerLlmChatCompressedEvent['value'], userMessageTimestamp: number, ) => { autonomousLoopTickResolverRef.current?.resetCache(); @@ -2315,7 +2309,7 @@ export const useGeminiStream = ( setLoopDetectionConfirmationRequest(null); if (result.userSelection === 'disable') { - config.getGeminiClient().getLoopDetectionService().disableForSession(); + config.getLlmClient().getLoopDetectionService().disableForSession(); addItem( { type: 'info', @@ -2390,9 +2384,9 @@ export const useGeminiStream = ( [addItem, commitItemInOrder, pendingHistoryItemRef, setPendingHistoryItem], ); - const processGeminiStreamEvents = useCallback( + const processLlmStreamEvents = useCallback( async ( - stream: AsyncIterable, + stream: AsyncIterable, userMessageTimestamp: number, signal: AbortSignal, submitType: SendMessageType, @@ -2401,7 +2395,7 @@ export const useGeminiStream = ( trackInteractionOwner = true, toolContinuationOwner?: ToolContinuationOwner, ): Promise => { - let geminiMessageBuffer = ''; + let llmMessageBuffer = ''; let thoughtBuffer = ''; let scheduledToolContinuation = false; let assistantOutputStarted = @@ -2456,9 +2450,9 @@ export const useGeminiStream = ( contentParts.push(queuedContent.value); } - geminiMessageBuffer = handleContentEvent( + llmMessageBuffer = handleContentEvent( contentParts.join(''), - geminiMessageBuffer, + llmMessageBuffer, userMessageTimestamp, assistantOutputStarted, ); @@ -2489,7 +2483,7 @@ export const useGeminiStream = ( ...pendingItem, omittedImageCount: (pendingItem.omittedImageCount ?? 0) + 1, }); - geminiMessageBuffer = ''; + llmMessageBuffer = ''; assistantOutputStarted = true; continue; } @@ -2503,7 +2497,7 @@ export const useGeminiStream = ( setPendingHistoryItem(null); } } - geminiMessageBuffer = ''; + llmMessageBuffer = ''; if (shouldDisplayImage) { setPendingHistoryItem({ type: assistantOutputStarted ? 'gemini_content' : 'gemini', @@ -2574,7 +2568,7 @@ export const useGeminiStream = ( } dualOutput?.processEvent(event); switch (event.type) { - case ServerGeminiEventType.Thought: + case ServerLlmEventType.Thought: // Subject-only chunks are discrete status updates for the // loading indicator and render immediately. Anything carrying // streamed text (with or without a subject) goes through the @@ -2588,7 +2582,7 @@ export const useGeminiStream = ( scheduleBufferedStreamFlush(); } break; - case ServerGeminiEventType.Content: { + case ServerLlmEventType.Content: { // Thinking is done once the answer starts streaming; reset the // title status. On the thinking→answer transition, flush any // buffered reasoning so the full thought is captured, then commit @@ -2621,7 +2615,7 @@ export const useGeminiStream = ( scheduleBufferedStreamFlush(); break; } - case ServerGeminiEventType.ToolCallRequest: + case ServerLlmEventType.ToolCallRequest: // Thinking is done once a tool call is issued; flush buffered // reasoning then commit it to history (collapsed) above the tool // output. @@ -2646,7 +2640,7 @@ export const useGeminiStream = ( // Best-effort — don't block on serialization errors } break; - case ServerGeminiEventType.UserCancelled: + case ServerLlmEventType.UserCancelled: flushBufferedStreamEvents(); toolCallRequests.length = 0; handleUserCancelledEvent(userMessageTimestamp); @@ -2654,21 +2648,21 @@ export const useGeminiStream = ( status: StreamProcessingStatus.UserCancelled, scheduledToolContinuation: false, }; - case ServerGeminiEventType.Error: + case ServerLlmEventType.Error: flushBufferedStreamEvents(); handleErrorEvent(event.value, userMessageTimestamp, submitType); break; - case ServerGeminiEventType.ChatCompressed: + case ServerLlmEventType.ChatCompressed: flushBufferedStreamEvents(); handleChatCompressionEvent(event.value, userMessageTimestamp); - geminiMessageBuffer = ''; + llmMessageBuffer = ''; assistantOutputStarted = false; break; - case ServerGeminiEventType.ToolCallConfirmation: - case ServerGeminiEventType.ToolCallResponse: + case ServerLlmEventType.ToolCallConfirmation: + case ServerLlmEventType.ToolCallResponse: flushBufferedStreamEvents(); break; - case ServerGeminiEventType.MaxSessionTurns: + case ServerLlmEventType.MaxSessionTurns: flushBufferedStreamEvents(); if (pendingHistoryItemRef.current) { commitItemInOrder( @@ -2678,10 +2672,10 @@ export const useGeminiStream = ( setPendingHistoryItem(null); } handleMaxSessionTurnsEvent(); - geminiMessageBuffer = ''; + llmMessageBuffer = ''; assistantOutputStarted = false; break; - case ServerGeminiEventType.SessionTokenLimitExceeded: + case ServerLlmEventType.SessionTokenLimitExceeded: flushBufferedStreamEvents(); if (pendingHistoryItemRef.current) { commitItemInOrder( @@ -2691,10 +2685,10 @@ export const useGeminiStream = ( setPendingHistoryItem(null); } handleSessionTokenLimitExceededEvent(event.value); - geminiMessageBuffer = ''; + llmMessageBuffer = ''; assistantOutputStarted = false; break; - case ServerGeminiEventType.Finished: + case ServerLlmEventType.Finished: flushBufferedStreamEvents(); // A thinking-only turn (no content/tool) still commits its // reasoning so it persists collapsed in history. @@ -2714,31 +2708,31 @@ export const useGeminiStream = ( ); setPendingHistoryItem(null); } - geminiMessageBuffer = ''; + llmMessageBuffer = ''; thoughtBuffer = ''; assistantOutputStarted = false; assistantInlineImageCount = 0; setThought(null); handleFinishedEvent( - event as ServerGeminiFinishedEvent, + event as ServerLlmFinishedEvent, userMessageTimestamp, ); break; - case ServerGeminiEventType.Citation: + case ServerLlmEventType.Citation: flushBufferedStreamEvents(); handleCitationEvent(event.value, userMessageTimestamp); if (showCitations(settings)) { - geminiMessageBuffer = ''; + llmMessageBuffer = ''; assistantOutputStarted = false; } break; - case ServerGeminiEventType.LoopDetected: + case ServerLlmEventType.LoopDetected: flushBufferedStreamEvents(); // handle later because we want to move pending history to history // before we add loop detected message to history loopDetectedRef.current = true; break; - case ServerGeminiEventType.Retry: + case ServerLlmEventType.Retry: // On fresh restart (escalation / rate-limit / invalid stream), // clear pending content and buffers to discard the failed attempt. // On continuation (recovery), keep the pending gemini item AND @@ -2755,7 +2749,7 @@ export const useGeminiStream = ( commitPendingThought(userMessageTimestamp); thoughtBuffer = ''; setThought(null); - geminiMessageBuffer = ''; + llmMessageBuffer = ''; assistantOutputStarted = false; assistantInlineImageCount = 0; } else { @@ -2764,7 +2758,7 @@ export const useGeminiStream = ( // Always discard tool call requests from the truncated/failed // attempt to prevent duplicate execution after escalation or // recovery. The recovery path now skips turns that already - // contain a functionCall (see geminiChat.ts), so this only + // contain a functionCall (see llm-chat.ts), so this only // clears stale requests from pre-RETRY accumulation. toolCallRequests.length = 0; // Show retry info if available (rate-limit / throttling errors) @@ -2775,7 +2769,7 @@ export const useGeminiStream = ( clearRetryCountdown(); } break; - case ServerGeminiEventType.ModelFallback: { + case ServerLlmEventType.ModelFallback: { // The primary model (or a prior fallback) exhausted its retry // budget on a capacity/availability error and the system is // switching to the next fallback model. Discard partial content @@ -2788,7 +2782,7 @@ export const useGeminiStream = ( commitPendingThought(userMessageTimestamp); thoughtBuffer = ''; setThought(null); - geminiMessageBuffer = ''; + llmMessageBuffer = ''; assistantOutputStarted = false; assistantInlineImageCount = 0; toolCallRequests.length = 0; @@ -2805,7 +2799,7 @@ export const useGeminiStream = ( ); break; } - case ServerGeminiEventType.HookSystemMessage: + case ServerLlmEventType.HookSystemMessage: flushBufferedStreamEvents(); // Display system message from Stop hooks with "Stop says:" prefix // First commit any pending AI response to ensure correct ordering @@ -2823,27 +2817,27 @@ export const useGeminiStream = ( } as HistoryItemWithoutId, userMessageTimestamp, ); - geminiMessageBuffer = ''; + llmMessageBuffer = ''; assistantOutputStarted = false; break; - case ServerGeminiEventType.UserPromptSubmitBlocked: + case ServerLlmEventType.UserPromptSubmitBlocked: flushBufferedStreamEvents(); handleUserPromptSubmitBlockedEvent( event.value, userMessageTimestamp, ); - geminiMessageBuffer = ''; + llmMessageBuffer = ''; assistantOutputStarted = false; break; - case ServerGeminiEventType.StopHookLoop: + case ServerLlmEventType.StopHookLoop: flushBufferedStreamEvents(); handleStopHookLoopEvent(event.value, userMessageTimestamp); - geminiMessageBuffer = ''; + llmMessageBuffer = ''; assistantOutputStarted = false; break; - case ServerGeminiEventType.ActiveGoal: + case ServerLlmEventType.ActiveGoal: break; - case ServerGeminiEventType.GoalState: + case ServerLlmEventType.GoalState: if (event.cause && shouldDisplayGoalStateCause(event.cause)) { flushBufferedStreamEvents(); if (pendingHistoryItemRef.current) { @@ -2861,7 +2855,7 @@ export const useGeminiStream = ( }, userMessageTimestamp, ); - geminiMessageBuffer = ''; + llmMessageBuffer = ''; assistantOutputStarted = false; } break; @@ -2902,7 +2896,7 @@ export const useGeminiStream = ( // mutation. In-flight entries from this submit fill ids not already // present in history (the history fingerprint for an id wins). const handledToolCallFingerprints = new Map( - geminiClient ? geminiClient.getHistoryToolCallFingerprints() : [], + llmClient ? llmClient.getHistoryToolCallFingerprints() : [], ); for (const [ providerCallId, @@ -2934,7 +2928,7 @@ export const useGeminiStream = ( ); if (repeatedDuplicateRequest?.providerCallId) { debugLogger.debug( - `[processGeminiStreamEvents] Dropping batch after repeated duplicate provider tool-call id: ${repeatedDuplicateRequest.providerCallId} (tool: ${repeatedDuplicateRequest.name})`, + `[processLlmStreamEvents] Dropping batch after repeated duplicate provider tool-call id: ${repeatedDuplicateRequest.providerCallId} (tool: ${repeatedDuplicateRequest.name})`, ); loopDetectedRef.current = true; return { @@ -2958,7 +2952,7 @@ export const useGeminiStream = ( const response = createDuplicateProviderToolCallResponse(request); debugLogger.debug( - `[processGeminiStreamEvents] Suppressing duplicate provider tool-call id: ${providerCallId} (tool: ${request.name})`, + `[processLlmStreamEvents] Suppressing duplicate provider tool-call id: ${providerCallId} (tool: ${request.name})`, ); dualOutput?.emitToolResult(request, response); duplicateResponses.push({ request, response }); @@ -3041,7 +3035,7 @@ export const useGeminiStream = ( handleErrorEvent, registerToolBatch, scheduleToolCalls, - geminiClient, + llmClient, handleChatCompressionEvent, handleFinishedEvent, handleMaxSessionTurnsEvent, @@ -3391,7 +3385,7 @@ export const useGeminiStream = ( acquireSubmissionLease(); const submissionGeneration = submissionLeaseGenerationRef.current; - // loopDetectedRef now gates tool-call scheduling (see processGeminiStream + // loopDetectedRef now gates tool-call scheduling (see processLlmStream // events), so it must reflect only this turn's state. Reset it // unconditionally at entry: if the previous turn detected a loop but threw // before its own post-stream reset, a stuck `true` would otherwise make @@ -3402,7 +3396,7 @@ export const useGeminiStream = ( // Reset turn-local ownership trackers at the very top of every // top-level submit (UserQuery, Retry, Cron, Notification, etc.). - // `prepareQueryForGemini` also resets `lastTurnUserItemRef`, but + // `prepareQueryForLlm` also resets `lastTurnUserItemRef`, but // Retry skips that path — without this earlier reset, a stale // ownership snapshot from the prior UserQuery would survive into // the retry's cancel info and let auto-restore wrongly truncate @@ -3435,7 +3429,7 @@ export const useGeminiStream = ( // non-continuation Retry event, so discard every run from the failed // attempt before the replacement stream starts. A different top-level // turn preserves what the user already saw, but must commit it before - // prepareQueryForGemini appends the next user item. + // prepareQueryForLlm appends the next user item. if (submitType === SendMessageType.Retry) { setPendingAssistantItems([]); const pendingItem = pendingHistoryItemRef.current; @@ -3578,7 +3572,7 @@ export const useGeminiStream = ( : { queryToSend: null, shouldProceed: false } : submitType === SendMessageType.Retry ? { queryToSend: query, shouldProceed: true } - : await prepareQueryForGemini( + : await prepareQueryForLlm( query, userMessageTimestamp, abortSignal, @@ -3808,7 +3802,7 @@ export const useGeminiStream = ( const providerSignal = inheritedToolContinuationOwner ? processingSignal : abortSignal; - const stream = geminiClient.sendMessageStream( + const stream = llmClient.sendMessageStream( finalQueryToSend, providerSignal, prompt_id!, @@ -3835,7 +3829,7 @@ export const useGeminiStream = ( }, ); - const processingResult = await processGeminiStreamEvents( + const processingResult = await processLlmStreamEvents( stream, userMessageTimestamp, processingSignal, @@ -3993,9 +3987,9 @@ export const useGeminiStream = ( // After the turn completes, wire up notifications for any background // dream / extraction tasks that were kicked off by the client. - if (geminiClient) { + if (llmClient) { const memoryTaskPromises = - geminiClient.consumePendingMemoryTaskPromises(); + llmClient.consumePendingMemoryTaskPromises(); for (const p of memoryTaskPromises) { void p.then((count) => { if (count > 0) { @@ -4116,8 +4110,8 @@ export const useGeminiStream = ( [ streamingState, setModelSwitchedFromQuotaError, - prepareQueryForGemini, - processGeminiStreamEvents, + prepareQueryForLlm, + processLlmStreamEvents, pendingHistoryItemRef, addItem, commitPendingAssistantItems, @@ -4125,7 +4119,7 @@ export const useGeminiStream = ( setPendingAssistantItems, setPendingHistoryItem, setInitError, - geminiClient, + llmClient, onAuthError, config, startNewPrompt, @@ -4292,19 +4286,19 @@ export const useGeminiStream = ( // wire — same trade-off upstream Claude Code makes when its // `StreamingToolExecutor.discard()` follows a // `yieldMissingToolResultBlocks` synthesis (`query.ts:733` + `:984`). - // Walk raw history WITHOUT cloning — `geminiClient.getHistory()` + // Walk raw history WITHOUT cloning — `llmClient.getHistory()` // returns `structuredClone(this.history)`, which on long sessions // (200+ entries with sizable tool outputs) costs several ms on // the React UI thread and visibly stalls streaming when the // dedup pass runs on every tool-completion batch. // `getHistoryFunctionResponseIds` walks history in place and // returns only the id Set this dispatcher needs. The - // GeminiClient implementation is mandatory — production and + // LlmClient implementation is mandatory — production and // test mocks both expose it. Skip the dedup pass entirely if // the client is missing (only happens in unit tests that // construct a hook without a client). - const historyCallIdsWithResponse: Set = geminiClient - ? geminiClient.getHistoryFunctionResponseIds() + const historyCallIdsWithResponse: Set = llmClient + ? llmClient.getHistoryFunctionResponseIds() : new Set(); const dedupedTools = completedAndReadyToSubmitTools.filter((tc) => historyCallIdsWithResponse.has(tc.request.callId), @@ -4322,7 +4316,7 @@ export const useGeminiStream = ( // (e.g. write_file under a project SKILLS path) would silently // skip the `skillsModifiedInSession` flip that gates the // skills-reload prompt at end-of-turn. Mirrors the - // `recordCompletedToolCall` loop below over `geminiTools` — + // `recordCompletedToolCall` loop below over `llmTools` — // filter to the same shape (non-client-initiated) so client // tools (which the original loop also skipped) stay skipped. // @@ -4339,7 +4333,7 @@ export const useGeminiStream = ( for (const tc of dedupedTools) { if (tc.request.isClientInitiated) continue; if (tc.status === 'cancelled') continue; - geminiClient?.recordCompletedToolCall( + llmClient?.recordCompletedToolCall( tc.request.name, tc.request.args as Record, ); @@ -4433,7 +4427,7 @@ export const useGeminiStream = ( !processedMemoryToolsRef.current.has(t.request.callId), ); - let geminiTools = completedAndReadyToSubmitTools.filter( + let llmTools = completedAndReadyToSubmitTools.filter( (t) => !t.request.isClientInitiated && !historyCallIdsWithResponse.has(t.request.callId), @@ -4464,13 +4458,13 @@ export const useGeminiStream = ( : undefined; const ownerToolCall = (liveActiveInteractionOwner - ? geminiTools.find( + ? llmTools.find( (toolCall) => liveOwnerForToolCall(toolCall) === liveActiveInteractionOwner, ) : undefined) ?? - geminiTools.find((toolCall) => liveOwnerForToolCall(toolCall)) ?? - geminiTools[0] ?? + llmTools.find((toolCall) => liveOwnerForToolCall(toolCall)) ?? + llmTools[0] ?? completedAndReadyToSubmitTools.find( (toolCall) => !toolCall.request.isClientInitiated, ); @@ -4482,7 +4476,7 @@ export const useGeminiStream = ( string >(); const secondaryTools = interactionOwner - ? geminiTools.filter( + ? llmTools.filter( (toolCall) => ownerForToolCall(toolCall) !== interactionOwner, ) : []; @@ -4495,7 +4489,7 @@ export const useGeminiStream = ( ); } if (toolCall.status !== 'cancelled') { - geminiClient?.recordCompletedToolCall( + llmClient?.recordCompletedToolCall( toolCall.request.name, toolCall.request.args as Record, ); @@ -4507,7 +4501,7 @@ export const useGeminiStream = ( secondaryTools.map((toolCall) => toolCall.request.callId), ); markToolsAsSubmitted([...secondaryCallIds]); - geminiTools = geminiTools.filter( + llmTools = llmTools.filter( (toolCall) => !secondaryCallIds.has(toolCall.request.callId), ); } @@ -4560,13 +4554,13 @@ export const useGeminiStream = ( } }; let toolGoalPermit: GoalTurnPermit | undefined; - const toolGoalContexts = geminiTools.map( + const toolGoalContexts = llmTools.map( (toolCall) => toolCall.request.goalContext, ); try { toolGoalPermit = sharedGoalPermit(toolGoalContexts); } catch (error) { - const callIds = geminiTools.map((toolCall) => toolCall.request.callId); + const callIds = llmTools.map((toolCall) => toolCall.request.callId); markToolsAsSubmitted(callIds); const reason = getErrorMessage(error); const bindings = new Map(); @@ -4620,7 +4614,7 @@ export const useGeminiStream = ( } if (active && activeGoalPermitValid) { markToolsAsSubmitted( - geminiTools.map((toolCall) => toolCall.request.callId), + llmTools.map((toolCall) => toolCall.request.callId), ); const reason = 'ToolResult batch is missing the active Goal context'; await failClosedGoalTurn(active, reason); @@ -4644,7 +4638,7 @@ export const useGeminiStream = ( const existing = goalTurnBindingsRef.current.get(toolGoalPermit.turnId); if (existing && !sameGoalPermit(existing.permit, toolGoalPermit)) { markToolsAsSubmitted( - geminiTools.map((toolCall) => toolCall.request.callId), + llmTools.map((toolCall) => toolCall.request.callId), ); const reason = 'ToolResult batch has a stale Goal context'; await failClosedGoalTurn(existing, reason); @@ -4713,7 +4707,7 @@ export const useGeminiStream = ( ); } const completedCallIds = new Set( - geminiTools.map((toolCall) => toolCall.request.callId), + llmTools.map((toolCall) => toolCall.request.callId), ); const secondaryCallIds = new Set( secondaryTools.map((toolCall) => toolCall.request.callId), @@ -4745,14 +4739,14 @@ export const useGeminiStream = ( promptId = pendingDuplicatePromptId; } - for (const toolCall of geminiTools) { - geminiClient?.recordCompletedToolCall( + for (const toolCall of llmTools) { + llmClient?.recordCompletedToolCall( toolCall.request.name, toolCall.request.args as Record, ); } - if (geminiTools.length === 0 && pendingDuplicateResponses.length === 0) { + if (llmTools.length === 0 && pendingDuplicateResponses.length === 0) { if (!promptId && terminalPromptId) { promptId = terminalPromptId; } @@ -4791,7 +4785,7 @@ export const useGeminiStream = ( status: 'success' | 'error' | 'cancelled'; }; const executableQueues = new Map(); - for (const toolCall of geminiTools) { + for (const toolCall of llmTools) { const queue = executableQueues.get(toolCall.request.callId) ?? []; queue.push({ request: toolCall.request, @@ -4877,7 +4871,7 @@ export const useGeminiStream = ( if (continuationWasCancelled()) { markToolsAsSubmitted( - geminiTools.map((toolCall) => toolCall.request.callId), + llmTools.map((toolCall) => toolCall.request.callId), ); if (toolGoalBinding) { await failClosedGoalTurn( @@ -4889,16 +4883,16 @@ export const useGeminiStream = ( return; } - // If all the tools were cancelled, don't submit a response to Gemini. - const allToolsCancelled = geminiTools.every( + // If all the tools were cancelled, don't submit a response to the model. + const allToolsCancelled = llmTools.every( (tc) => tc.status === 'cancelled', ); if (allToolsCancelled && pendingDuplicateResponses.length === 0) { - if (geminiClient) { + if (llmClient) { // We need to manually add the function responses to the history // so the model knows the tools were cancelled. - geminiClient.addHistory({ + llmClient.addHistory({ role: 'user', parts: responsesToSend, }); @@ -4907,7 +4901,7 @@ export const useGeminiStream = ( config.getArenaAgentClient()?.reportCancelled(); } - const callIdsToMarkAsSubmitted = geminiTools.map( + const callIdsToMarkAsSubmitted = llmTools.map( (toolCall) => toolCall.request.callId, ); markToolsAsSubmitted(callIdsToMarkAsSubmitted); @@ -4921,7 +4915,7 @@ export const useGeminiStream = ( return; } - const callIdsToMarkAsSubmitted = geminiTools.map( + const callIdsToMarkAsSubmitted = llmTools.map( (toolCall) => toolCall.request.callId, ); @@ -4931,7 +4925,7 @@ export const useGeminiStream = ( // An explicit inline `/model ` override wins for the whole // turn, so skip skill-tool writes (including the undefined-clears case) // while it is active. - for (const toolCall of geminiTools) { + for (const toolCall of llmTools) { if ('modelOverride' in toolCall.response) { if ( inlineModelOverrideActiveRef.current || @@ -4959,18 +4953,18 @@ export const useGeminiStream = ( // Emit tool results to dual output sidecar (if enabled) if (dualOutput) { - for (const toolCall of geminiTools) { + for (const toolCall of llmTools) { dualOutput.emitToolResult(toolCall.request, toolCall.response); } } markToolsAsSubmitted(callIdsToMarkAsSubmitted); - const terminatesGoalTurn = geminiTools.some( + const terminatesGoalTurn = llmTools.some( (toolCall) => toolCall.response.terminateTurn === true, ); if (terminatesGoalTurn && toolGoalBinding) { - geminiClient.addHistory({ role: 'user', parts: responsesToSend }); + llmClient.addHistory({ role: 'user', parts: responsesToSend }); let goalFinishFailed = false; try { await config.getChatRecordingService()?.flush(); @@ -5024,7 +5018,7 @@ export const useGeminiStream = ( // Fire tool-use summary generation in parallel with the next API call. // The fast-model latency is hidden behind the main-model streaming. // Fire-and-forget: failures are silent and never block the turn. - // Subagent exclusion is implicit — useGeminiStream only drives the + // Subagent exclusion is implicit — useLlmStream only drives the // main session; subagents run through agents/runtime/ with their own loop. if (config.getEmitToolUseSummaries()) { // Only summarize successful tools. Error/cancelled entries push @@ -5035,7 +5029,7 @@ export const useGeminiStream = ( // prefixes but not prevent this kind of polluted-input hallucination. // Goal tools already render authoritative lifecycle copy, which a // generated summary can contradict while verification is pending. - const successfulTools = geminiTools.filter( + const successfulTools = llmTools.filter( (tc) => tc.status === 'success' && tc.request.name !== ToolNames.GET_GOAL && @@ -5132,7 +5126,7 @@ export const useGeminiStream = ( const backgroundLaunchExhaustedCapacity = backgroundTaskRegistry.getMaxConcurrentBackgroundAgents() === 1 && !backgroundTaskRegistry.canStartBackgroundAgent() && - geminiTools.some((toolCall) => { + llmTools.some((toolCall) => { const display = toolCall.response.resultDisplay; return ( toolCall.request.name === ToolNames.AGENT && @@ -5145,7 +5139,7 @@ export const useGeminiStream = ( ); }); if (backgroundLaunchExhaustedCapacity) { - geminiClient?.addHistory({ role: 'user', parts: responsesToSend }); + llmClient?.addHistory({ role: 'user', parts: responsesToSend }); if (toolGoalBinding) { await failClosedGoalTurn( toolGoalBinding, @@ -5241,7 +5235,7 @@ export const useGeminiStream = ( [ submitQuery, markToolsAsSubmitted, - geminiClient, + llmClient, performMemoryRefresh, modelSwitchedFromQuotaError, config, @@ -5329,7 +5323,7 @@ export const useGeminiStream = ( const toolName = toolCall.request.name; const fileName = path.basename(filePath); const toolCallWithSnapshotFileName = `${timestamp}-${fileName}-${toolName}.json`; - const clientHistory = geminiClient?.getHistoryShallow(); + const clientHistory = llmClient?.getHistoryShallow(); const toolCallWithSnapshotFilePath = path.join( checkpointDir, toolCallWithSnapshotFileName, @@ -5363,7 +5357,7 @@ export const useGeminiStream = ( } }; saveRestorableToolCalls(); - }, [toolCalls, config, onDebugMessage, history, geminiClient, storage]); + }, [toolCalls, config, onDebugMessage, history, llmClient, storage]); // ─── Unified notification queue (cron + background agents) ────── const notificationQueueRef = useRef< @@ -5795,7 +5789,7 @@ export const useGeminiStream = ( const batch = teammateQueueRef.current.splice(0); // Render one compact `● …` line per teammate report; the full // envelope goes only to the model (the USER bubble is suppressed - // for SendMessageType.Teammate in prepareQueryForGemini). + // for SendMessageType.Teammate in prepareQueryForLlm). for (const entry of batch) { if (!entry.displayed) { addItem( diff --git a/packages/cli/src/ui/hooks/useBackgroundTaskView.ts b/packages/cli/src/ui/hooks/useBackgroundTaskView.ts index 24bc9e5693d..7918d8dd2fe 100644 --- a/packages/cli/src/ui/hooks/useBackgroundTaskView.ts +++ b/packages/cli/src/ui/hooks/useBackgroundTaskView.ts @@ -215,7 +215,7 @@ export function useBackgroundTaskView( // touching the task map.) Extract tasks also intentionally // stay out of this view — they fire on every UserQuery and // their completion is already covered by the `memory_saved` - // toast in useGeminiStream. + // toast in useLlmStream. // // Cap retained terminal entries — MemoryManager.tasks Map has no // eviction path, so completed/failed dreams accumulate forever diff --git a/packages/cli/src/ui/hooks/useBranchCommand.test.ts b/packages/cli/src/ui/hooks/useBranchCommand.test.ts index e742b0da524..c2ab9f31fc3 100644 --- a/packages/cli/src/ui/hooks/useBranchCommand.test.ts +++ b/packages/cli/src/ui/hooks/useBranchCommand.test.ts @@ -134,7 +134,7 @@ describe('useBranchCommand', () => { findSessionTitlesByPrefix, }), getChatRecordingService: () => ({ finalize, flush }), - getGeminiClient: () => ({ initialize: vi.fn() }), + getLlmClient: () => ({ initialize: vi.fn() }), getBackgroundTaskRegistry: () => backgroundTaskRegistry, getMonitorRegistry: () => monitorRegistry, getBackgroundShellRegistry: () => backgroundShellRegistry, @@ -443,9 +443,9 @@ describe('useBranchCommand', () => { ); }); - it('initializes GeminiClient with SessionStartSource.Branch', async () => { + it('initializes LlmClient with SessionStartSource.Branch', async () => { const initialize = vi.fn().mockResolvedValue(undefined); - config.getGeminiClient = () => ({ initialize }); + config.getLlmClient = () => ({ initialize }); const { result } = renderHook(() => useBranchCommand(makeOptions())); await act(async () => { @@ -563,9 +563,9 @@ describe('useBranchCommand', () => { expect(startNewSessionUI).not.toHaveBeenCalled(); }); - it('rolls core back to the parent session when getGeminiClient().initialize() rejects after swap', async () => { + it('rolls core back to the parent session when getLlmClient().initialize() rejects after swap', async () => { // The reviewer's scenario: config.startNewSession succeeds (core is now - // on the fork), but then getGeminiClient().initialize() rejects. Without + // on the fork), but then getLlmClient().initialize() rejects. Without // rollback, core stays on the fork while UI is still on the parent, so // the recorder silently writes subsequent user input into an orphan // JSONL. This test pins the rollback invariant — after the failure core @@ -591,7 +591,7 @@ describe('useBranchCommand', () => { .fn() .mockRejectedValueOnce(new Error('init boom')) // fork init fails .mockResolvedValueOnce(undefined); // rollback re-init succeeds - config.getGeminiClient = () => ({ initialize }); + config.getLlmClient = () => ({ initialize }); const { result } = renderHook(() => useBranchCommand(makeOptions())); await act(async () => { @@ -646,7 +646,7 @@ describe('useBranchCommand', () => { .fn() .mockRejectedValueOnce(new Error('init boom')) .mockRejectedValueOnce(new Error('rollback boom')); - config.getGeminiClient = () => ({ initialize }); + config.getLlmClient = () => ({ initialize }); const { result } = renderHook(() => useBranchCommand(makeOptions())); await act(async () => { diff --git a/packages/cli/src/ui/hooks/useBranchCommand.ts b/packages/cli/src/ui/hooks/useBranchCommand.ts index 4a56c32e457..20c2889771e 100644 --- a/packages/cli/src/ui/hooks/useBranchCommand.ts +++ b/packages/cli/src/ui/hooks/useBranchCommand.ts @@ -200,7 +200,7 @@ export function useBranchCommand( config.startNewSession(newSessionId, resumed); coreSwapped = true; await waitForGoalRuntime(config); - await config.getGeminiClient()?.initialize?.(SessionStartSource.Branch); + await config.getLlmClient()?.initialize?.(SessionStartSource.Branch); // 8. Swap UI. Once this commits, rolling core back is unsafe — // it would leave UI on the branch but recorder writing into @@ -273,7 +273,7 @@ export function useBranchCommand( // Re-hydrate chat history against the restored session. Best- // effort: if this throws too, sessionId + recorder are still // back on the parent, which is the load-bearing invariant. - await config.getGeminiClient()?.initialize?.(); + await config.getLlmClient()?.initialize?.(); } catch (rollbackErr) { config .getDebugLogger() diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts index bf883743f81..896359540c3 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts @@ -50,13 +50,13 @@ export type ScheduleFn = ( export type MarkToolsAsSubmittedFn = (callIds: string[]) => void; export type TrackedScheduledToolCall = ScheduledToolCall & { - responseSubmittedToGemini?: boolean; + responseSubmittedToLlm?: boolean; }; export type TrackedValidatingToolCall = ValidatingToolCall & { - responseSubmittedToGemini?: boolean; + responseSubmittedToLlm?: boolean; }; export type TrackedWaitingToolCall = WaitingToolCall & { - responseSubmittedToGemini?: boolean; + responseSubmittedToLlm?: boolean; }; /** * NOTE on inherited fields: `pid?` and `promoteAbortController?` come @@ -90,13 +90,13 @@ const _ASSERT_INHERITED_FIELDS_PRESENT: _AssertExecutingHasPid & void _ASSERT_INHERITED_FIELDS_PRESENT; export type TrackedExecutingToolCall = ExecutingToolCall & { - responseSubmittedToGemini?: boolean; + responseSubmittedToLlm?: boolean; }; export type TrackedCompletedToolCall = CompletedToolCall & { - responseSubmittedToGemini?: boolean; + responseSubmittedToLlm?: boolean; }; export type TrackedCancelledToolCall = CancelledToolCall & { - responseSubmittedToGemini?: boolean; + responseSubmittedToLlm?: boolean; }; export type TrackedToolCall = @@ -156,8 +156,8 @@ export function useReactToolScheduler( ); // Start with the new core state, then layer on the existing UI state // to ensure UI-only properties like pid are preserved. - const responseSubmittedToGemini = - existingTrackedCall?.responseSubmittedToGemini ?? false; + const responseSubmittedToLlm = + existingTrackedCall?.responseSubmittedToLlm ?? false; if (coreTc.status === 'executing') { // `...coreTc` already spreads `pid` and @@ -167,7 +167,7 @@ export function useReactToolScheduler( // version of this call. return { ...coreTc, - responseSubmittedToGemini, + responseSubmittedToLlm, liveOutput: (existingTrackedCall as TrackedExecutingToolCall) ?.liveOutput, }; @@ -186,7 +186,7 @@ export function useReactToolScheduler( // tool call). return { ...coreTc, - responseSubmittedToGemini, + responseSubmittedToLlm, liveOutput: undefined, pid: undefined, promoteAbortController: undefined, @@ -291,7 +291,7 @@ export function useReactToolScheduler( setToolCallsForDisplay((prevCalls) => prevCalls.map((tc) => callIdsToMark.includes(tc.request.callId) - ? { ...tc, responseSubmittedToGemini: true } + ? { ...tc, responseSubmittedToLlm: true } : tc, ), ); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index ab5c5ee79b5..2f9ef3133a3 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -241,7 +241,7 @@ describe('useResumeCommand', () => { }; const startNewSession = vi.fn(); const clearPendingState = vi.fn(); - const geminiClient = { + const llmClient = { initialize: vi.fn().mockResolvedValue(undefined), }; const resetMonitorRegistry = vi.fn(); @@ -249,7 +249,7 @@ describe('useResumeCommand', () => { const config = { getSessionId: () => 'old-session-id', getTargetDir: () => '/tmp', - getGeminiClient: () => geminiClient, + getLlmClient: () => llmClient, startNewSession: vi.fn(), getGoalRuntimeReady: vi.fn().mockResolvedValue({}), getBackgroundTaskRegistry: () => ({ @@ -323,8 +323,8 @@ describe('useResumeCommand', () => { }), ); expect(startNewSession).toHaveBeenCalledWith('session-2'); - expect(geminiClient.initialize).toHaveBeenCalledTimes(1); - expect(geminiClient.initialize).toHaveBeenCalledWith(); + expect(llmClient.initialize).toHaveBeenCalledTimes(1); + expect(llmClient.initialize).toHaveBeenCalledWith(); expect(historyManager.clearItems).toHaveBeenCalledTimes(1); expect(historyManager.loadHistory).toHaveBeenCalledTimes(1); expect(clearPendingState).toHaveBeenCalledTimes(1); @@ -349,7 +349,7 @@ describe('useResumeCommand', () => { const config = { getSessionId: () => 'old-session-id', getTargetDir: () => '/tmp', - getGeminiClient: () => ({ + getLlmClient: () => ({ initialize: vi.fn().mockResolvedValue(undefined), }), startNewSession: vi.fn(), @@ -423,14 +423,14 @@ describe('useResumeCommand', () => { loadHistory: vi.fn(), }; const startNewSession = vi.fn(); - const geminiClient = { + const llmClient = { initialize: vi.fn().mockResolvedValue(undefined), }; const config = { getSessionId: () => 'old-session-id', getTargetDir: () => '/tmp', - getGeminiClient: () => geminiClient, + getLlmClient: () => llmClient, startNewSession: vi.fn(), getGoalRuntimeReady: vi.fn().mockResolvedValue({}), getBackgroundTaskRegistry: () => ({ @@ -506,7 +506,7 @@ describe('useResumeCommand', () => { it('applies collapseOnResume policy when resuming a session', async () => { const startNewSession = vi.fn(); - const geminiClient = { + const llmClient = { initialize: vi.fn(), }; const resetMonitorRegistry = vi.fn(); @@ -514,7 +514,7 @@ describe('useResumeCommand', () => { const config = { getSessionId: () => 'old-session-id', getTargetDir: () => '/tmp', - getGeminiClient: () => geminiClient, + getLlmClient: () => llmClient, startNewSession: vi.fn(), getGoalRuntimeReady: vi.fn().mockResolvedValue({}), getBackgroundTaskRegistry: () => ({ @@ -600,7 +600,7 @@ describe('useResumeCommand', () => { loadHistory: vi.fn(), }; const startNewSession = vi.fn(); - const geminiClient = { + const llmClient = { initialize: vi.fn(), }; const buildRecoveredBackgroundAgentsNotice = vi @@ -610,7 +610,7 @@ describe('useResumeCommand', () => { const config = { getSessionId: () => 'old-session-id', getTargetDir: () => '/tmp', - getGeminiClient: () => geminiClient, + getLlmClient: () => llmClient, startNewSession: vi.fn(), getGoalRuntimeReady: vi.fn().mockResolvedValue({}), getBackgroundTaskRegistry: () => ({ @@ -822,7 +822,7 @@ describe('useResumeCommand', () => { it('rolls core back when persisted Goal state is malformed', async () => { const startNewSession = vi.fn(); - const geminiClient = { + const llmClient = { initialize: vi.fn().mockResolvedValue(undefined), }; const goalFailure = new Error('unsupported Goal lifecycle record'); @@ -830,7 +830,7 @@ describe('useResumeCommand', () => { const config = { getSessionId: () => 'old-session-id', getTargetDir: () => '/tmp', - getGeminiClient: () => geminiClient, + getLlmClient: () => llmClient, startNewSession: vi.fn(), getGoalRuntimeReady: vi.fn().mockRejectedValue(goalFailure), getBackgroundTaskRegistry: () => ({ @@ -907,6 +907,6 @@ describe('useResumeCommand', () => { }), expect.any(Number), ); - expect(geminiClient.initialize).not.toHaveBeenCalled(); + expect(llmClient.initialize).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index 20f83a0d8d0..1d29a9d7aa6 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -178,7 +178,7 @@ export function useResumeCommand( config .getChatRecordingService() ?.rebuildTurnBoundaries(sessionData.conversation.messages); - await config.getGeminiClient()?.initialize?.(); + await config.getLlmClient()?.initialize?.(); const recovered = await config.loadPausedBackgroundAgents(sessionId); if (recovered.length > 0) { diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index 48093c8c294..39b66934ea4 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -70,7 +70,7 @@ const mockConfig = { }), getBaseLlmClient: vi.fn(), getUseModelRouter: () => false, - getGeminiClient: () => null, // No client needed for these tests + getLlmClient: () => null, // No client needed for these tests getShellExecutionConfig: () => ({ terminalWidth: 80, terminalHeight: 24 }), getChatRecordingService: vi.fn(() => undefined), getMessageBus: vi.fn().mockReturnValue(undefined), diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx index ce425177ea2..d46f38a55c7 100644 --- a/packages/cli/src/ui/startInteractiveUI.tsx +++ b/packages/cli/src/ui/startInteractiveUI.tsx @@ -364,7 +364,7 @@ export async function startInteractiveUI( // record lands, and `--resume` refuses to load an empty one. // Non-emptiness here relies on config.shutdown() flushing the // recorder first — it is registered earlier in the cleanup chain - // in gemini.tsx; keep that registration order. + // in llm.tsx; keep that registration order. if (isValidSessionId(sessionId) && (await stat(sessionFile)).size > 0) { writeStdoutLine( `\n${t('To continue this session, run')}\nqwen --resume ${sessionId}`, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 92d9a2b2e7c..27a6783007f 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -39,7 +39,7 @@ export enum StreamingState { } // Copied from server/src/core/turn.ts for CLI usage -export enum GeminiEventType { +export enum LlmEventType { Content = 'content', ToolCallRequest = 'tool_call_request', // Add other event types if the UI hook needs to handle them @@ -152,7 +152,7 @@ export type HistoryItemUser = HistoryItemBase & { sentToModel?: boolean; }; -export type HistoryItemGemini = HistoryItemBase & { +export type HistoryItemLlm = HistoryItemBase & { type: 'gemini'; text: string; images?: InlineImageData[]; @@ -160,20 +160,20 @@ export type HistoryItemGemini = HistoryItemBase & { timestamp?: number; }; -export type HistoryItemGeminiContent = HistoryItemBase & { +export type HistoryItemLlmContent = HistoryItemBase & { type: 'gemini_content'; text: string; images?: InlineImageData[]; omittedImageCount?: number; }; -export type HistoryItemGeminiThought = HistoryItemBase & { +export type HistoryItemLlmThought = HistoryItemBase & { type: 'gemini_thought'; text: string; durationMs?: number; }; -export type HistoryItemGeminiThoughtContent = HistoryItemBase & { +export type HistoryItemLlmThoughtContent = HistoryItemBase & { type: 'gemini_thought_content'; text: string; }; @@ -691,10 +691,10 @@ export type HistoryItemWithoutId = | HistoryItemUser | HistoryItemNotification | HistoryItemUserShell - | HistoryItemGemini - | HistoryItemGeminiContent - | HistoryItemGeminiThought - | HistoryItemGeminiThoughtContent + | HistoryItemLlm + | HistoryItemLlmContent + | HistoryItemLlmThought + | HistoryItemLlmThoughtContent | HistoryItemInfo | HistoryItemError | HistoryItemWarning @@ -878,7 +878,7 @@ export interface ConsoleMessageItem { /** * Result type for a slash command that should immediately result in a prompt - * being submitted to the Gemini model. + * being submitted to the model. */ export interface SubmitPromptResult { type: 'submit_prompt'; @@ -895,7 +895,7 @@ export interface SubmitPromptResult { } /** - * Defines the result of the slash command processor for its consumer (useGeminiStream). + * Defines the result of the slash command processor for its consumer (useLlmStream). */ export type SlashCommandProcessorResult = | { diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index 6f22078600f..857139cd187 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -190,7 +190,7 @@ const MarkdownDisplayInternal: React.FC = ({ const tableSeparatorRegex = TABLE_SEPARATOR_RE; // Rendered-height-aware slice of the pending preview (shared with - // useGeminiStream's incremental commit — see pending-rendered-height.ts — so the + // useLlmStream's incremental commit — see pending-rendered-height.ts — so the // two agree on how tall the content renders). Guarantees the live frame never // exceeds the viewport, so ink cannot fall into its from-top full-redraw path // (the scroll-to-top lock). Note keptLines can be 0 when even the first diff --git a/packages/cli/src/ui/utils/historyMapping.test.ts b/packages/cli/src/ui/utils/historyMapping.test.ts index 3b82e8f48db..1b71ff89772 100644 --- a/packages/cli/src/ui/utils/historyMapping.test.ts +++ b/packages/cli/src/ui/utils/historyMapping.test.ts @@ -56,7 +56,7 @@ function userItem( } as HistoryItem; } -function geminiItem(id: number): HistoryItem { +function llmItem(id: number): HistoryItem { return { type: 'gemini', id, text: `response ${id}` } as HistoryItem; } @@ -93,9 +93,9 @@ describe('computeApiTruncationIndex', () => { it('rewinds to the first user turn (keep nothing)', () => { const ui: HistoryItem[] = [ userItem(1), - geminiItem(2), + llmItem(2), userItem(3), - geminiItem(4), + llmItem(4), ]; const api: Content[] = [ userContent('prompt 1'), @@ -110,9 +110,9 @@ describe('computeApiTruncationIndex', () => { it('rewinds to the second user turn (keep first turn)', () => { const ui: HistoryItem[] = [ userItem(1), - geminiItem(2), + llmItem(2), userItem(3), - geminiItem(4), + llmItem(4), ]; const api: Content[] = [ userContent('prompt 1'), @@ -127,11 +127,11 @@ describe('computeApiTruncationIndex', () => { it('rewinds to the third user turn', () => { const ui: HistoryItem[] = [ userItem(1), - geminiItem(2), + llmItem(2), userItem(3), - geminiItem(4), + llmItem(4), userItem(5), - geminiItem(6), + llmItem(6), ]; const api: Content[] = [ userContent('prompt 1'), @@ -147,7 +147,7 @@ describe('computeApiTruncationIndex', () => { describe('with startup context entry', () => { it('keeps startup context when rewinding to the first turn', () => { - const ui: HistoryItem[] = [userItem(1), geminiItem(2)]; + const ui: HistoryItem[] = [userItem(1), llmItem(2)]; const api: Content[] = [ startupEntry(), userContent('prompt 1'), @@ -160,9 +160,9 @@ describe('computeApiTruncationIndex', () => { it('keeps startup + first turn when rewinding to second turn', () => { const ui: HistoryItem[] = [ userItem(1), - geminiItem(2), + llmItem(2), userItem(3), - geminiItem(4), + llmItem(4), ]; const api: Content[] = [ startupEntry(), @@ -189,11 +189,11 @@ describe('computeApiTruncationIndex', () => { // early, silently dropping a turn's context. const ui: HistoryItem[] = [ userItem(1), - geminiItem(2), + llmItem(2), userItem(3), - geminiItem(4), + llmItem(4), userItem(5), - geminiItem(6), + llmItem(6), ]; const api: Content[] = [ startupEntry(), @@ -227,9 +227,9 @@ describe('computeApiTruncationIndex', () => { }); const ui: HistoryItem[] = [ userItem(1), - geminiItem(2), + llmItem(2), userItem(3), - geminiItem(4), + llmItem(4), ]; const api: Content[] = [ startupEntry(), @@ -247,10 +247,10 @@ describe('computeApiTruncationIndex', () => { it('skips functionResponse entries when counting user prompts', () => { const ui: HistoryItem[] = [ userItem(1), - geminiItem(2), + llmItem(2), // tool_group items are not type 'user', they don't affect the count userItem(5), - geminiItem(6), + llmItem(6), ]; const api: Content[] = [ userContent('prompt 1'), @@ -270,11 +270,11 @@ describe('computeApiTruncationIndex', () => { it('returns -1 when not enough user prompts found', () => { const ui: HistoryItem[] = [ userItem(1), - geminiItem(2), + llmItem(2), userItem(3), - geminiItem(4), + llmItem(4), userItem(5), - geminiItem(6), + llmItem(6), ]; // After compression, API history may be shorter than expected const api: Content[] = [ @@ -289,14 +289,14 @@ describe('computeApiTruncationIndex', () => { it('maps post-compression UI turns from the latest compressed marker', () => { const ui: HistoryItem[] = [ userItem(1, 'pre-compression prompt'), - geminiItem(2), + llmItem(2), compressionItem(3), userItem(4, 'post 1'), - geminiItem(5), + llmItem(5), userItem(6, 'post 2'), - geminiItem(7), + llmItem(7), userItem(8, 'post 3'), - geminiItem(9), + llmItem(9), ]; const api: Content[] = [ startupEntry(), @@ -318,7 +318,7 @@ describe('computeApiTruncationIndex', () => { it('does not rewind to UI turns before a successful compression marker', () => { const ui: HistoryItem[] = [ userItem(1, 'pre-compression prompt'), - geminiItem(2), + llmItem(2), compressionItem(3), userItem(4, 'post compression'), ]; @@ -335,10 +335,10 @@ describe('computeApiTruncationIndex', () => { it('does not treat no-op compression markers as collapsed history', () => { const ui: HistoryItem[] = [ userItem(1, 'first prompt'), - geminiItem(2), + llmItem(2), compressionItem(3, CompressionStatus.NOOP), userItem(4, 'second prompt'), - geminiItem(5), + llmItem(5), ]; const api: Content[] = [ startupEntry(), @@ -358,9 +358,9 @@ describe('computeApiTruncationIndex', () => { // prefix and drop every real turn (R5-1 entrance 3). const ui: HistoryItem[] = [ userItem(1, 'pre 1'), - geminiItem(2), + llmItem(2), userItem(3, 'pre 2'), - geminiItem(4), + llmItem(4), ]; const api: Content[] = [ startupEntry(), @@ -380,16 +380,16 @@ describe('computeApiTruncationIndex', () => { const fastCompressedHistory = () => { const ui: HistoryItem[] = [ userItem(1, 'pre 1'), - geminiItem(2), + llmItem(2), userItem(3, 'pre 2'), - geminiItem(4), + llmItem(4), userItem(5, 'pre 3'), - geminiItem(6), + llmItem(6), compressionItem(7, CompressionStatus.COMPRESSED, 'fast'), userItem(8, 'post 1'), - geminiItem(9), + llmItem(9), userItem(10, 'post 2'), - geminiItem(11), + llmItem(11), ]; const api: Content[] = [ startupEntry(), @@ -429,10 +429,10 @@ describe('computeApiTruncationIndex', () => { it('still blocks turns absorbed by a later summarizing compression', () => { const ui: HistoryItem[] = [ userItem(1, 'pre fast'), - geminiItem(2), + llmItem(2), compressionItem(3, CompressionStatus.COMPRESSED, 'fast'), userItem(4, 'between compressions'), - geminiItem(5), + llmItem(5), compressionItem(6, CompressionStatus.COMPRESSED, 'summarize'), userItem(7, 'post summarize'), ]; @@ -460,7 +460,7 @@ describe('computeApiTruncationIndex', () => { } as HistoryItem; const ui: HistoryItem[] = [ userItem(1, 'pre-compression prompt'), - geminiItem(2), + llmItem(2), legacyMarker, userItem(4, 'post compression'), ]; @@ -506,9 +506,9 @@ describe('computeApiTruncationIndex', () => { it('does not count a cleared media-only entry as a user prompt', () => { const ui: HistoryItem[] = [ userItem(1, 'hello'), - geminiItem(2), + llmItem(2), userItem(3, 'world'), - geminiItem(4), + llmItem(4), ]; const api: Content[] = [ startupEntry(), @@ -536,12 +536,12 @@ describe('computeApiTruncationIndex', () => { it('keeps the full pre-marker history when a cleared entry precedes a fast marker', () => { const ui: HistoryItem[] = [ userItem(1, 'pre 1'), - geminiItem(2), + llmItem(2), compressionItem(3, CompressionStatus.COMPRESSED, 'fast'), userItem(4, 'post 1'), - geminiItem(5), + llmItem(5), userItem(6, 'post 2'), - geminiItem(7), + llmItem(7), ]; const api: Content[] = [ startupEntry(), @@ -568,9 +568,9 @@ describe('computeApiTruncationIndex', () => { }; const ui: HistoryItem[] = [ userItem(1, 'check this image'), - geminiItem(2), + llmItem(2), userItem(3, 'world'), - geminiItem(4), + llmItem(4), ]; const api: Content[] = [ startupEntry(), @@ -597,9 +597,9 @@ describe('computeApiTruncationIndex', () => { }; const ui: HistoryItem[] = [ userItem(1, prefixPromptText), - geminiItem(2), + llmItem(2), userItem(3, 'world'), - geminiItem(4), + llmItem(4), ]; const api: Content[] = [ startupEntry(), @@ -632,9 +632,9 @@ describe('computeApiTruncationIndex', () => { }; const ui: HistoryItem[] = [ userItem(1, exactPlaceholderText), - geminiItem(2), + llmItem(2), userItem(3, 'world'), - geminiItem(4), + llmItem(4), ]; const api: Content[] = [ startupEntry(), @@ -663,11 +663,11 @@ describe('computeApiTruncationIndex', () => { const exactPlaceholderText = '[Old inline media cleared: image/png]'; const ui: HistoryItem[] = [ userItem(1, 'hello'), - geminiItem(2), + llmItem(2), userItem(3, exactPlaceholderText), - geminiItem(4), + llmItem(4), userItem(5, 'world'), - geminiItem(6), + llmItem(6), ]; const api: Content[] = [ startupEntry(), @@ -693,14 +693,14 @@ describe('computeApiTruncationIndex', () => { // isUserTextContent). Both sides agree → correct truncation index. const ui: HistoryItem[] = [ userItem(1, 'first prompt'), - geminiItem(2), + llmItem(2), { type: 'notification', id: 3, text: 'btw side question', } as HistoryItem, userItem(5, 'next prompt'), - geminiItem(6), + llmItem(6), ]; const btwMergedIntoToolResult: Content = { role: 'user', @@ -729,10 +729,10 @@ describe('computeApiTruncationIndex', () => { it('ignores slash-command items when counting user turns', () => { const ui: HistoryItem[] = [ userItem(1, 'hello'), - geminiItem(2), + llmItem(2), userItem(3, '/help'), // slash command — should be skipped userItem(5, 'world'), - geminiItem(6), + llmItem(6), ]; const api: Content[] = [ userContent('hello'), @@ -748,11 +748,11 @@ describe('computeApiTruncationIndex', () => { it('counts path-like slash prompts that were sent to the model', () => { const ui: HistoryItem[] = [ userItem(1, 'hello'), - geminiItem(2), + llmItem(2), userItem(3, '/api/apiFunction/接口的实现'), - geminiItem(4), + llmItem(4), userItem(5, 'world'), - geminiItem(6), + llmItem(6), ]; const api: Content[] = [ userContent('hello'), @@ -769,11 +769,11 @@ describe('computeApiTruncationIndex', () => { it('counts slash command invocations explicitly marked as sent to the model', () => { const ui: HistoryItem[] = [ userItem(1, 'hello'), - geminiItem(2), + llmItem(2), userItem(3, '/filecmd', true), - geminiItem(4), + llmItem(4), userItem(5, 'world'), - geminiItem(6), + llmItem(6), ]; const api: Content[] = [ userContent('hello'), @@ -790,7 +790,7 @@ describe('computeApiTruncationIndex', () => { describe('single turn', () => { it('handles rewinding the only turn', () => { - const ui: HistoryItem[] = [userItem(1), geminiItem(2)]; + const ui: HistoryItem[] = [userItem(1), llmItem(2)]; const api: Content[] = [ userContent('prompt 1'), modelContent('response 1'), @@ -841,7 +841,7 @@ describe('isRealUserTurn', () => { }); it('returns false for non-user items', () => { - expect(isRealUserTurn(geminiItem(1))).toBe(false); + expect(isRealUserTurn(llmItem(1))).toBe(false); expect( isRealUserTurn({ type: 'info', id: 1, text: 'info' } as HistoryItem), ).toBe(false); diff --git a/packages/cli/src/ui/utils/pending-rendered-height.ts b/packages/cli/src/ui/utils/pending-rendered-height.ts index c803e05d3a2..49e345062cf 100644 --- a/packages/cli/src/ui/utils/pending-rendered-height.ts +++ b/packages/cli/src/ui/utils/pending-rendered-height.ts @@ -13,7 +13,7 @@ import { readInlineMathSpanAt } from './inline-math.js'; * terminal row: a wide/CJK line wraps, and a markdown table renders ~2 rows per * data row (TableRenderer draws a separator between every data row) plus * borders and vertical margin. Both the incremental scrollback commit - * (useGeminiStream) and the render-side safety-net slice (MarkdownDisplay) use + * (useLlmStream) and the render-side safety-net slice (MarkdownDisplay) use * this module so they agree on how tall the pending content will render — a * divergent estimate would let the safety net engage out of step with the * commit and flicker. diff --git a/packages/cli/src/ui/utils/restoreGoal.ts b/packages/cli/src/ui/utils/restoreGoal.ts index 0b98151e01f..847d2a56ae7 100644 --- a/packages/cli/src/ui/utils/restoreGoal.ts +++ b/packages/cli/src/ui/utils/restoreGoal.ts @@ -39,7 +39,7 @@ export interface RestorableGoal { * * The iteration count is carried so the MAX_GOAL_ITERATIONS safety cap survives * resume instead of resetting to zero. `checking` items persist the running - * count (see useGeminiStream's continuation handler); `set` items predate any + * count (see useLlmStream's continuation handler); `set` items predate any * iteration, so they restore at 0. * * `setAt` is carried so elapsed time keeps measuring from the original `/goal`. diff --git a/packages/cli/src/ui/utils/todoSnapshot.test.ts b/packages/cli/src/ui/utils/todoSnapshot.test.ts index e8bb2be1d46..70ff64bb107 100644 --- a/packages/cli/src/ui/utils/todoSnapshot.test.ts +++ b/packages/cli/src/ui/utils/todoSnapshot.test.ts @@ -109,7 +109,7 @@ function makeEmptyTodoToolGroup( return item; } -function makeGeminiHistoryItem(text: string, id: number): HistoryItem { +function makeLlmHistoryItem(text: string, id: number): HistoryItem { return { type: 'gemini', id, @@ -135,8 +135,8 @@ describe('getStickyTodos', () => { const history = [ makeTodoToolGroup('first task', 1), makeTodoToolGroup('latest history task', 2), - makeGeminiHistoryItem('First response after todo', 3), - makeGeminiHistoryItem('Second response after todo', 4), + makeLlmHistoryItem('First response after todo', 3), + makeLlmHistoryItem('Second response after todo', 4), ] as HistoryItem[]; expect(getStickyTodos(history, [])).toEqual([ @@ -168,7 +168,7 @@ describe('getStickyTodos', () => { it('keeps sticky todos hidden when the latest history todo is still the newest item', () => { const history = [ - makeGeminiHistoryItem('Earlier response', 1), + makeLlmHistoryItem('Earlier response', 1), makeTodoToolGroup('latest history task', 2), ] as HistoryItem[]; @@ -178,7 +178,7 @@ describe('getStickyTodos', () => { it('keeps sticky todos hidden when the latest history todo has only one following item', () => { const history = [ makeTodoToolGroup('latest history task', 1), - makeGeminiHistoryItem('One response after todo', 2), + makeLlmHistoryItem('One response after todo', 2), ] as HistoryItem[]; expect(getStickyTodos(history, [])).toBeNull(); @@ -187,8 +187,8 @@ describe('getStickyTodos', () => { it('shows sticky todos once later history has likely moved the inline todo away', () => { const history = [ makeTodoToolGroup('latest history task', 1), - makeGeminiHistoryItem('First response after todo', 2), - makeGeminiHistoryItem('Second response after todo', 3), + makeLlmHistoryItem('First response after todo', 2), + makeLlmHistoryItem('Second response after todo', 3), ] as HistoryItem[]; expect(getStickyTodos(history, [])).toEqual([ @@ -217,8 +217,8 @@ describe('getStickyTodos', () => { ], 1, ), - makeGeminiHistoryItem('First response after todo', 2), - makeGeminiHistoryItem('Second response after todo', 3), + makeLlmHistoryItem('First response after todo', 2), + makeLlmHistoryItem('Second response after todo', 3), ] as HistoryItem[]; expect(getStickyTodos(history, [])).toBeNull(); @@ -230,8 +230,8 @@ describe('getStickyTodos', () => { const history = [ makeUserHistoryItem('Do the tasks', 1), makeTodoToolGroup('task from turn N', 2), - makeGeminiHistoryItem('Working on it', 3), - makeGeminiHistoryItem('Done with turn N', 4), + makeLlmHistoryItem('Working on it', 3), + makeLlmHistoryItem('Done with turn N', 4), makeUserHistoryItem('Next question', 5), ] as HistoryItem[]; @@ -246,8 +246,8 @@ describe('getStickyTodos', () => { const history = [ makeUserHistoryItem('Do the tasks', 1), makeTodoToolGroup('task from turn N', 2), - makeGeminiHistoryItem('Working on it', 3), - makeGeminiHistoryItem('Done with turn N', 4), + makeLlmHistoryItem('Working on it', 3), + makeLlmHistoryItem('Done with turn N', 4), makeUserHistoryItem('/stats', 5, false), ] as HistoryItem[]; @@ -259,8 +259,8 @@ describe('getStickyTodos', () => { const history = [ makeUserHistoryItem('Do the tasks', 1), makeTodoToolGroup('current task', 2), - makeGeminiHistoryItem('Working on it', 3), - makeGeminiHistoryItem('Still working', 4), + makeLlmHistoryItem('Working on it', 3), + makeLlmHistoryItem('Still working', 4), ] as HistoryItem[]; expect(getStickyTodos(history, [])).toEqual([ diff --git a/packages/cli/src/utils/earlyInputCapture.ts b/packages/cli/src/utils/earlyInputCapture.ts index 4a6584e28fc..6bd8aa6136a 100644 --- a/packages/cli/src/utils/earlyInputCapture.ts +++ b/packages/cli/src/utils/earlyInputCapture.ts @@ -232,7 +232,7 @@ function shouldReplayPendingAtStop(pending: Buffer): boolean { /** * Start early input capture - * Call immediately after setting raw mode in gemini.tsx + * Call immediately after setting raw mode in llm.tsx */ export function startEarlyInputCapture(): void { if (isCapturing || !process.stdin.isTTY) { diff --git a/packages/cli/src/utils/headlessSafetyWarnings.ts b/packages/cli/src/utils/headlessSafetyWarnings.ts index 587521bce12..b23ae776f3a 100644 --- a/packages/cli/src/utils/headlessSafetyWarnings.ts +++ b/packages/cli/src/utils/headlessSafetyWarnings.ts @@ -16,7 +16,7 @@ export const HEADLESS_YOLO_NO_SANDBOX_WARNING = * configured, we're already inside a sandbox, approval mode is not YOLO, or * the user explicitly suppressed the notice. * - * The call site (gemini.tsx) is responsible for gating on + * The call site (llm.tsx) is responsible for gating on * `!config.isInteractive()` — this helper deliberately ignores interactivity * so it stays pure and unit-testable. * @@ -36,7 +36,7 @@ export function getHeadlessYoloSafetyWarning( // `SANDBOX` is set by the sandbox transport itself: macOS seatbelt sets // it to `sandbox-exec`, Docker/Podman to the container name (e.g. // `qwen-code-sandbox`). Match the rest of the codebase - // (sandboxConfig.ts, gemini.tsx, Footer.tsx, prompts.ts, …) which all + // (sandboxConfig.ts, llm.tsx, Footer.tsx, prompts.ts, …) which all // treat any non-empty value as "inside a sandbox". A strict 1/true // check here misfires inside real sandboxes, where the helper would // wrongly emit a "no sandbox" warning despite the run being contained. diff --git a/packages/cli/src/utils/startupProfiler.ts b/packages/cli/src/utils/startupProfiler.ts index c2029e323a9..4c5b56b6ba4 100644 --- a/packages/cli/src/utils/startupProfiler.ts +++ b/packages/cli/src/utils/startupProfiler.ts @@ -11,7 +11,7 @@ * high-resolution timestamps at key phases of CLI startup and writes a JSON * report to ~/.qwen/startup-perf/ on finalization. * - * Usage (already wired in index.ts / gemini.tsx): + * Usage (already wired in index.ts / llm.tsx): * initStartupProfiler() — call once at process start to record T0 * profileCheckpoint('name') — call at each phase boundary (sequential) * recordStartupEvent('name', attrs?) — record a discrete event (multi-fire allowed) @@ -266,7 +266,7 @@ function computeDerivedPhases(): DerivedPhases { // discover did the model actually receive an updated tool list. We must // pick the FIRST `gemini_tools_updated` event whose timestamp is >= // `mcp_first_tool_registered`, because earlier `setTools()` calls fire - // from `GeminiClient.initialize() -> startChat()` (built-in tools only) + // from `LlmClient.initialize() -> startChat()` (built-in tools only) // and from `SkillTool` post-construction refresh — both happen BEFORE // MCP discovery starts under PR-A, so naively taking the first // `gemini_tools_updated` would give a misleading negative lag. diff --git a/packages/cli/src/utils/uncaught-exception-handler.ts b/packages/cli/src/utils/uncaught-exception-handler.ts index 852bd5c5668..4ddb05e48a1 100644 --- a/packages/cli/src/utils/uncaught-exception-handler.ts +++ b/packages/cli/src/utils/uncaught-exception-handler.ts @@ -6,9 +6,9 @@ import { writeStderrLine } from './stdioHelpers.js'; -// These helpers live in a leaf module (no import of cli.ts or gemini.tsx) so -// both the entry point and the lazily-loaded gemini.tsx can share them. A -// static import of cli.ts from gemini.tsx makes esbuild hoist the entry into a +// These helpers live in a leaf module (no import of cli.ts or llm.tsx) so +// both the entry point and the lazily-loaded llm.tsx can share them. A +// static import of cli.ts from llm.tsx makes esbuild hoist the entry into a // shared chunk under `splitting: true`, which silently disables the bootstrap // guard at the bottom of cli.ts and leaves the bundled CLI dead. @@ -53,7 +53,7 @@ export function isExpectedPtyRaceError(error: unknown): boolean { * before the session ID (and thus the debug-log path) is known. Benign PTY * teardown races are suppressed; anything else is reported to stderr and fatal. * - * `setupUncaughtExceptionHandler` in gemini.tsx removes this handler and + * `setupUncaughtExceptionHandler` in llm.tsx removes this handler and * installs a session-aware replacement once interactive startup is far enough * along to leave the alternate screen and write the debug file. Exactly one * listener must be active: two would conflict (the first calls `process.exit` diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index e0b73a9d46e..2b9c8b47719 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -104,7 +104,7 @@ "src/ui/hooks/useCommandCompletion.test.ts", "src/ui/hooks/useFocus.test.ts", "src/ui/hooks/useFolderTrust.test.ts", - "src/ui/hooks/useGeminiStream.test.tsx", + "src/ui/hooks/use-llm-stream.test.tsx", "src/ui/hooks/useKeypress.test.ts", "src/ui/hooks/usePhraseCycler.test.ts", "src/ui/utils/computeStats.test.ts", diff --git a/packages/core/src/agents/background-agent-resume.test.ts b/packages/core/src/agents/background-agent-resume.test.ts index 367711dc42e..960ea41b9ff 100644 --- a/packages/core/src/agents/background-agent-resume.test.ts +++ b/packages/core/src/agents/background-agent-resume.test.ts @@ -182,7 +182,7 @@ describe('BackgroundAgentResumeService', () => { getSessionId: () => 'session-1', getProjectRoot: () => tempDir, getCliVersion: () => 'test-version', - getGeminiClient: () => + getLlmClient: () => options.currentForkRuntime ? { getChat: () => ({ diff --git a/packages/core/src/agents/background-agent-resume.ts b/packages/core/src/agents/background-agent-resume.ts index 8cdb4ccf9ef..78172fb0d7b 100644 --- a/packages/core/src/agents/background-agent-resume.ts +++ b/packages/core/src/agents/background-agent-resume.ts @@ -1585,11 +1585,11 @@ export class BackgroundAgentResumeService { CurrentForkRuntime | undefined > { try { - const geminiClient = this.config.getGeminiClient(); - const generationConfig = geminiClient?.getChat().getGenerationConfig(); + const llmClient = this.config.getLlmClient(); + const generationConfig = llmClient?.getChat().getGenerationConfig(); if (!generationConfig?.systemInstruction) { debugLogger.debug( - '[BackgroundAgentResume] Current fork runtime unavailable (no_system_instruction): parent Gemini client or system instruction is missing.', + '[BackgroundAgentResume] Current fork runtime unavailable (no_system_instruction): parent LLM client or system instruction is missing.', ); return undefined; } diff --git a/packages/core/src/agents/forkedAgent.cache.test.ts b/packages/core/src/agents/forkedAgent.cache.test.ts index 5601a3cfc53..2d927d09a1c 100644 --- a/packages/core/src/agents/forkedAgent.cache.test.ts +++ b/packages/core/src/agents/forkedAgent.cache.test.ts @@ -15,15 +15,15 @@ import { import type { Content, GenerateContentConfig } from '@google/genai'; import type { Config } from '../config/config.js'; import { AuthType } from '../core/contentGenerator.js'; -import { GeminiChat, StreamEventType } from '../core/geminiChat.js'; +import { LlmChat, StreamEventType } from '../core/llm-chat.js'; import { createRuntimeContentGeneratorView } from '../models/content-generator-config.js'; import type { RuntimeContentGeneratorView } from './runtime/agent-context.js'; -vi.mock('../core/geminiChat.js', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../core/llm-chat.js', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, - GeminiChat: vi.fn(), + LlmChat: vi.fn(), }; }); @@ -231,7 +231,7 @@ describe('CacheSafeParams', () => { describe('runForkedAgent (cache path)', () => { beforeEach(() => { clearCacheSafeParams(); - vi.mocked(GeminiChat).mockReset(); + vi.mocked(LlmChat).mockReset(); vi.mocked(createRuntimeContentGeneratorView).mockReset(); }); @@ -284,12 +284,12 @@ describe('runForkedAgent (cache path)', () => { ); const enableManualPlanExitNotices = vi.fn(); - vi.mocked(GeminiChat).mockImplementation( + vi.mocked(LlmChat).mockImplementation( () => ({ sendMessageStream: mockSendMessageStream, enableManualPlanExitNotices, - }) as unknown as GeminiChat, + }) as unknown as LlmChat, ); const mockConfig = {} as unknown as Config; @@ -300,10 +300,10 @@ describe('runForkedAgent (cache path)', () => { cacheSafeParams: getCacheSafeParams()!, }); - // Verify GeminiChat was constructed with the full generationConfig + // Verify LlmChat was constructed with the full generationConfig // (including tools) — createForkedChat retains tools for speculation callers - expect(GeminiChat).toHaveBeenCalledOnce(); - const ctorArgs = vi.mocked(GeminiChat).mock.calls[0]; + expect(LlmChat).toHaveBeenCalledOnce(); + const ctorArgs = vi.mocked(LlmChat).mock.calls[0]; const chatGenerationConfig = ctorArgs[1] as GenerateContentConfig; expect(chatGenerationConfig.tools).toEqual([ { @@ -360,9 +360,9 @@ describe('runForkedAgent (cache path)', () => { } return Promise.resolve(generate()); }); - vi.mocked(GeminiChat).mockImplementation( + vi.mocked(LlmChat).mockImplementation( () => - ({ sendMessageStream: mockSendMessageStream }) as unknown as GeminiChat, + ({ sendMessageStream: mockSendMessageStream }) as unknown as LlmChat, ); const result = await runForkedAgent({ @@ -419,11 +419,11 @@ describe('runForkedAgent (cache path)', () => { }, ); - vi.mocked(GeminiChat).mockImplementation( + vi.mocked(LlmChat).mockImplementation( () => ({ sendMessageStream: mockSendMessageStream, - }) as unknown as GeminiChat, + }) as unknown as LlmChat, ); const schema = { @@ -488,11 +488,11 @@ describe('runForkedAgent (cache path)', () => { }, ); - vi.mocked(GeminiChat).mockImplementation( + vi.mocked(LlmChat).mockImplementation( () => ({ sendMessageStream: mockSendMessageStream, - }) as unknown as GeminiChat, + }) as unknown as LlmChat, ); const mockConfig = { @@ -575,11 +575,11 @@ describe('runForkedAgent (cache path)', () => { }, ); - vi.mocked(GeminiChat).mockImplementation( + vi.mocked(LlmChat).mockImplementation( () => ({ sendMessageStream: mockSendMessageStream, - }) as unknown as GeminiChat, + }) as unknown as LlmChat, ); const mockConfig = { @@ -667,11 +667,11 @@ describe('runForkedAgent (cache path)', () => { }, ); - vi.mocked(GeminiChat).mockImplementation( + vi.mocked(LlmChat).mockImplementation( () => ({ sendMessageStream: mockSendMessageStream, - }) as unknown as GeminiChat, + }) as unknown as LlmChat, ); const mockConfig = { @@ -741,11 +741,11 @@ describe('runForkedAgent (cache path)', () => { }, ); - vi.mocked(GeminiChat).mockImplementation( + vi.mocked(LlmChat).mockImplementation( () => ({ sendMessageStream: mockSendMessageStream, - }) as unknown as GeminiChat, + }) as unknown as LlmChat, ); await runForkedAgent({ @@ -800,11 +800,11 @@ describe('runForkedAgent (cache path)', () => { }, ); - vi.mocked(GeminiChat).mockImplementation( + vi.mocked(LlmChat).mockImplementation( () => ({ sendMessageStream: mockSendMessageStream, - }) as unknown as GeminiChat, + }) as unknown as LlmChat, ); await runForkedAgent({ @@ -870,11 +870,11 @@ describe('runForkedAgent (cache path)', () => { }, ); - vi.mocked(GeminiChat).mockImplementation( + vi.mocked(LlmChat).mockImplementation( () => ({ sendMessageStream: mockSendMessageStream, - }) as unknown as GeminiChat, + }) as unknown as LlmChat, ); const result = await runForkedAgent({ @@ -936,11 +936,11 @@ describe('runForkedAgent (cache path)', () => { }, ); - vi.mocked(GeminiChat).mockImplementation( + vi.mocked(LlmChat).mockImplementation( () => ({ sendMessageStream: mockSendMessageStream, - }) as unknown as GeminiChat, + }) as unknown as LlmChat, ); const result = await runForkedAgent({ @@ -998,11 +998,11 @@ describe('runForkedAgent (cache path)', () => { }, ); - vi.mocked(GeminiChat).mockImplementation( + vi.mocked(LlmChat).mockImplementation( () => ({ sendMessageStream: mockSendMessageStream, - }) as unknown as GeminiChat, + }) as unknown as LlmChat, ); const schema = { @@ -1040,7 +1040,7 @@ describe('runForkedAgent (cache path)', () => { // runForkedAgent cache path requires cacheSafeParams to be passed explicitly; // the caller (btwCommand, suggestionGenerator) is responsible for checking // getCacheSafeParams() and handling null before calling runForkedAgent. - // This test verifies the GeminiChat path is taken when cacheSafeParams present. + // This test verifies the LlmChat path is taken when cacheSafeParams present. // The null guard lives in the callers. void mockConfig; // suppress unused }); diff --git a/packages/core/src/agents/forkedAgent.ts b/packages/core/src/agents/forkedAgent.ts index 69d3fc86ac4..72eb22ca41f 100644 --- a/packages/core/src/agents/forkedAgent.ts +++ b/packages/core/src/agents/forkedAgent.ts @@ -9,7 +9,7 @@ * * The two execution paths are selected by whether cacheSafeParams is supplied: * - * WITH cacheSafeParams → GeminiChat single-turn, shares parent prompt + * WITH cacheSafeParams → LlmChat single-turn, shares parent prompt * cache (systemInstruction + history). Tools are * stripped by default (NO_TOOLS) to prevent * function calls; pass preserveTools: true to @@ -40,7 +40,7 @@ import { type RuntimeContentGeneratorView, } from './runtime/agent-context.js'; import { ApprovalMode, type Config } from '../config/config.js'; -import { GeminiChat, StreamEventType } from '../core/geminiChat.js'; +import { LlmChat, StreamEventType } from '../core/llm-chat.js'; import { createRuntimeContentGeneratorView } from '../models/content-generator-config.js'; import { createApprovalModeOverride } from '../tools/agent/agent.js'; import { createDebugLogger } from '../utils/debugLogger.js'; @@ -124,7 +124,7 @@ function copyHistoryContainers(history: Content[]): Content[] { /** * Save cache-safe params after a successful main conversation turn. - * Called from GeminiClient.sendMessageStream() on successful completion. + * Called from LlmClient.sendMessageStream() on successful completion. */ export function saveCacheSafeParams( generationConfig: GenerateContentConfig, @@ -207,7 +207,7 @@ const NO_TOOLS = Object.freeze({ tools: [] as const }) as Pick< >; /** - * Create an isolated GeminiChat that shares the main conversation's + * Create an isolated LlmChat that shares the main conversation's * generationConfig (including systemInstruction, tools, and history). * * Used by runForkedAgent (cache path) and directly by speculation.ts which @@ -216,14 +216,14 @@ const NO_TOOLS = Object.freeze({ tools: [] as const }) as Pick< export function createForkedChat( config: Config, params: CacheSafeParams, -): GeminiChat { +): LlmChat { const maxHistoryEntries = 40; const history = params.history.length > maxHistoryEntries ? params.history.slice(-maxHistoryEntries) : params.history; - return new GeminiChat( + return new LlmChat( config, { ...params.generationConfig, diff --git a/packages/core/src/agents/runtime/agent-core.test.ts b/packages/core/src/agents/runtime/agent-core.test.ts index 9f74d732131..3e69a939c85 100644 --- a/packages/core/src/agents/runtime/agent-core.test.ts +++ b/packages/core/src/agents/runtime/agent-core.test.ts @@ -49,7 +49,7 @@ import { runWithInvocationContext, type InvocationContextV1, } from '../../utils/invocation-context.js'; -import { GeminiChat } from '../../core/geminiChat.js'; +import { LlmChat } from '../../core/llm-chat.js'; import { ContextState } from './agent-headless.js'; import type { ToolResultBoundaryObservation } from '../../tools/tool-result-boundary-diagnostics.js'; import { @@ -86,7 +86,7 @@ describe('AgentCore.createChat manual plan-exit notice ownership', () => { { max_turns: 1 }, ); const enableSpy = vi.spyOn( - GeminiChat.prototype, + LlmChat.prototype, 'enableManualPlanExitNotices', ); diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 1784527e582..27b368bb96e 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -35,9 +35,9 @@ import { import { createDuplicateProviderToolCallResponse, findRepeatedDuplicateProviderToolCall, - GeminiEventType, + LlmEventType, markDuplicateProviderToolCallResponseSent, - type ServerGeminiStreamEvent, + type ServerLlmStreamEvent, type ToolCallRequestInfo, } from '../../core/turn.js'; import { LoopDetectionService } from '../../services/loopDetectionService.js'; @@ -74,7 +74,7 @@ import type { FunctionDeclaration, GenerateContentResponseUsageMetadata, } from '@google/genai'; -import { GeminiChat } from '../../core/geminiChat.js'; +import { LlmChat } from '../../core/llm-chat.js'; import { assembleSystemPrompt } from '../../core/prompts.js'; import { dedupeToolCallsById, @@ -242,7 +242,8 @@ export function extractParentToolNames( new Set( ( generationConfig?.tools as - Array<{ functionDeclarations?: FunctionDeclaration[] }> | undefined + | Array<{ functionDeclarations?: FunctionDeclaration[] }> + | undefined ) ?.flatMap((tool) => tool.functionDeclarations ?? []) .map((declaration) => declaration.name) @@ -510,17 +511,17 @@ export class AgentCore { // ─── Chat Creation ──────────────────────────────────────── /** - * Creates a GeminiChat instance configured for this agent. + * Creates a LlmChat instance configured for this agent. * * @param context - Context state for template variable substitution. * @param options - Chat creation options. * - `interactive`: When true, omits the "non-interactive mode" system prompt suffix. - * @returns A configured GeminiChat, or undefined if initialization fails. + * @returns A configured LlmChat, or undefined if initialization fails. */ async createChat( context: ContextState, options?: CreateChatOptions, - ): Promise { + ): Promise { if ( !this.promptConfig.systemPrompt && !this.promptConfig.renderedSystemPrompt && @@ -576,7 +577,7 @@ export class AgentCore { } try { - const chat = new GeminiChat( + const chat = new LlmChat( this.runtimeContext, generationConfig, startHistory, @@ -761,7 +762,7 @@ export class AgentCore { * - maxTimeMinutes is exceeded * - The abortController signal fires * - * @param chat - The GeminiChat session to use. + * @param chat - The LlmChat session to use. * @param initialMessages - The first messages to send (e.g., user task prompt). * @param toolsList - Available tool declarations. * @param abortController - Controls cancellation of the current loop. @@ -769,7 +770,7 @@ export class AgentCore { * @returns ReasoningLoopResult with the final text, terminate mode, and turns used. */ async runReasoningLoop( - chat: GeminiChat, + chat: LlmChat, initialMessages: Content[], toolsList: FunctionDeclaration[], abortController: AbortController, @@ -875,7 +876,7 @@ export class AgentCore { } private async _runReasoningLoopInner( - chat: GeminiChat, + chat: LlmChat, initialMessages: Content[], toolsList: FunctionDeclaration[], abortController: AbortController, @@ -900,7 +901,7 @@ export class AgentCore { loopDetector.reset( `${this.runtimeContext.getSessionId()}#${this.subagentId}`, ); - const checkSubagentLoop = (event: ServerGeminiStreamEvent): boolean => { + const checkSubagentLoop = (event: ServerLlmStreamEvent): boolean => { if (loopDetector.checkAlwaysOnSafeties(event)) { return true; } @@ -990,7 +991,7 @@ export class AgentCore { if (streamEvent.type === 'retry') { if ( checkSubagentLoop({ - type: GeminiEventType.Retry, + type: LlmEventType.Retry, ...('isContinuation' in streamEvent ? { isContinuation: streamEvent.isContinuation } : {}), @@ -1012,7 +1013,7 @@ export class AgentCore { continue; } - // GeminiChat already mutated its own history; surface to the debug + // LlmChat already mutated its own history; surface to the debug // log so subagent compactions show up alongside the main session's. if (streamEvent.type === 'compressed') { this.runtimeContext @@ -1061,7 +1062,7 @@ export class AgentCore { if ( thoughtSummary && checkSubagentLoop({ - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: thoughtSummary, }) ) { @@ -1074,7 +1075,7 @@ export class AgentCore { if ( responseText && checkSubagentLoop({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: responseText, }) ) { @@ -1087,7 +1088,7 @@ export class AgentCore { const toolName = String(fc.name); if ( checkSubagentLoop({ - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: fc.id ?? `${toolName}-${Date.now()}`, providerCallId: getProviderToolCallId(fc), @@ -1113,7 +1114,7 @@ export class AgentCore { if ( finishReason && checkSubagentLoop({ - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: finishReason, usageMetadata: resp.usageMetadata, @@ -1539,7 +1540,8 @@ export class AgentCore { const registeredTool = this.runtimeContext .getToolRegistry() .getTool(toolName) as - { serverName?: unknown; serverToolName?: unknown } | undefined; + | { serverName?: unknown; serverToolName?: unknown } + | undefined; if ( typeof registeredTool?.serverName !== 'string' || typeof registeredTool.serverToolName !== 'string' diff --git a/packages/core/src/agents/runtime/agent-headless.test.ts b/packages/core/src/agents/runtime/agent-headless.test.ts index 075863e9eab..ac86bd7baf6 100644 --- a/packages/core/src/agents/runtime/agent-headless.test.ts +++ b/packages/core/src/agents/runtime/agent-headless.test.ts @@ -29,8 +29,8 @@ import { resolveContentGeneratorConfigWithSources, AuthType, } from '../../core/contentGenerator.js'; -import { GeminiChat } from '../../core/geminiChat.js'; -import { GeminiEventType } from '../../core/turn.js'; +import { LlmChat } from '../../core/llm-chat.js'; +import { LlmEventType } from '../../core/turn.js'; import { getToolCallFingerprint, normalizeModelToolCallIds, @@ -64,7 +64,7 @@ import { ToolNames } from '../../tools/tool-names.js'; import { normalizeToolNameForProvider } from '../../utils/tool-name-utils.js'; import { LoopDetectionService } from '../../services/loopDetectionService.js'; -vi.mock('../../core/geminiChat.js'); +vi.mock('../../core/llm-chat.js'); vi.mock('../../core/contentGenerator.js', async (importOriginal) => { const actual = await importOriginal(); @@ -349,13 +349,13 @@ describe('subagent.ts', () => { mockGetHistoryToolCallFingerprints = vi.fn( () => new Map(), ); - vi.mocked(GeminiChat).mockImplementation( + vi.mocked(LlmChat).mockImplementation( () => ({ sendMessageStream: mockSendMessageStream, setLastPromptTokenCount: vi.fn(), getHistoryToolCallFingerprints: mockGetHistoryToolCallFingerprints, - }) as unknown as GeminiChat, + }) as unknown as LlmChat, ); // Default mock for executeToolCall @@ -376,7 +376,7 @@ describe('subagent.ts', () => { const getGenerationConfigFromMock = ( callIndex = 0, ): GenerateContentConfig & { systemInstruction?: string | Content } => { - const callArgs = vi.mocked(GeminiChat).mock.calls[callIndex]; + const callArgs = vi.mocked(LlmChat).mock.calls[callIndex]; const generationConfig = callArgs?.[1]; // Ensure it's defined before proceeding expect(generationConfig).toBeDefined(); @@ -497,10 +497,10 @@ describe('subagent.ts', () => { }); describe('execute - Initialization and Prompting', () => { - it('should correctly template the system prompt and initialize GeminiChat', async () => { + it('should correctly template the system prompt and initialize LlmChat', async () => { const { config } = await createMockConfig(); - vi.mocked(GeminiChat).mockClear(); + vi.mocked(LlmChat).mockClear(); const promptConfig: PromptConfig = { systemPrompt: 'Hello ${name}, your task is ${task}.', @@ -522,9 +522,9 @@ describe('subagent.ts', () => { await scope.execute(context); - // Check if GeminiChat was initialized correctly by the subagent - expect(GeminiChat).toHaveBeenCalledTimes(1); - const callArgs = vi.mocked(GeminiChat).mock.calls[0]; + // Check if LlmChat was initialized correctly by the subagent + expect(LlmChat).toHaveBeenCalledTimes(1); + const callArgs = vi.mocked(LlmChat).mock.calls[0]; // Check Generation Config const generationConfig = getGenerationConfigFromMock(); @@ -581,7 +581,7 @@ describe('subagent.ts', () => { followUpContext.set('task_prompt', 'Follow-up task'); await scope.execute(followUpContext); - expect(GeminiChat).toHaveBeenCalledTimes(1); + expect(LlmChat).toHaveBeenCalledTimes(1); expect(toolRegistry.warmAll).toHaveBeenCalledTimes(1); expect(mockSendMessageStream).toHaveBeenCalledTimes(2); expect(mockSendMessageStream.mock.calls[0][1].message).toEqual([ @@ -785,7 +785,7 @@ describe('subagent.ts', () => { '# Output language preference: English\nRespond in English.'; vi.spyOn(config, 'getUserMemory').mockReturnValue(userMemoryContent); - vi.mocked(GeminiChat).mockClear(); + vi.mocked(LlmChat).mockClear(); const promptConfig: PromptConfig = { systemPrompt: 'You are a test agent.', @@ -824,7 +824,7 @@ describe('subagent.ts', () => { vi.spyOn(config, 'getUserMemory').mockReturnValue(''); vi.spyOn(config, 'getAutoMemoryPrompt').mockReturnValue(''); - vi.mocked(GeminiChat).mockClear(); + vi.mocked(LlmChat).mockClear(); const promptConfig: PromptConfig = { systemPrompt: 'You are a test agent.', @@ -854,7 +854,7 @@ describe('subagent.ts', () => { vi.spyOn(config, 'getUserMemory').mockReturnValue(' \n\n '); vi.spyOn(config, 'getAutoMemoryPrompt').mockReturnValue(''); - vi.mocked(GeminiChat).mockClear(); + vi.mocked(LlmChat).mockClear(); const promptConfig: PromptConfig = { systemPrompt: 'You are a test agent.', @@ -886,7 +886,7 @@ describe('subagent.ts', () => { autoMemoryContent, ); - vi.mocked(GeminiChat).mockClear(); + vi.mocked(LlmChat).mockClear(); const promptConfig: PromptConfig = { systemPrompt: 'You are a test agent.', @@ -917,7 +917,7 @@ describe('subagent.ts', () => { it('should replace env history with initialMessages when both initialMessages and systemPrompt are set', async () => { const { config } = await createMockConfig(); - vi.mocked(GeminiChat).mockClear(); + vi.mocked(LlmChat).mockClear(); const initialMessages: Content[] = [ { role: 'user', parts: [{ text: 'prior user turn' }] }, @@ -943,7 +943,7 @@ describe('subagent.ts', () => { await scope.execute(context); - const callArgs = vi.mocked(GeminiChat).mock.calls[0]; + const callArgs = vi.mocked(LlmChat).mock.calls[0]; const generationConfig = getGenerationConfigFromMock(); const history = callArgs[2]; @@ -958,7 +958,7 @@ describe('subagent.ts', () => { it('should skip env history when initialMessages is an empty array', async () => { const { config } = await createMockConfig(); - vi.mocked(GeminiChat).mockClear(); + vi.mocked(LlmChat).mockClear(); vi.mocked(getInitialChatHistory).mockClear(); const promptConfig: PromptConfig = { @@ -980,7 +980,7 @@ describe('subagent.ts', () => { await scope.execute(context); - const callArgs = vi.mocked(GeminiChat).mock.calls[0]; + const callArgs = vi.mocked(LlmChat).mock.calls[0]; const generationConfig = getGenerationConfigFromMock(); expect(generationConfig.systemInstruction).toContain('System Agent.'); @@ -990,7 +990,7 @@ describe('subagent.ts', () => { it('should use renderedSystemPrompt verbatim and bypass templating', async () => { const { config } = await createMockConfig(); - vi.mocked(GeminiChat).mockClear(); + vi.mocked(LlmChat).mockClear(); const rendered = 'Verbatim parent system prompt ${name}'; const promptConfig: PromptConfig = { @@ -2872,7 +2872,7 @@ describe('subagent.ts', () => { { text: 'Let me think...' as string, thought: true }, { text: 'Here is the answer.' as string }, ]); - vi.mocked(GeminiChat).mockImplementation( + vi.mocked(LlmChat).mockImplementation( () => ({ sendMessageStream: mockSendMessageStream, @@ -2880,7 +2880,7 @@ describe('subagent.ts', () => { getHistoryToolCallFingerprints: vi.fn( () => new Map(), ), - }) as unknown as GeminiChat, + }) as unknown as LlmChat, ); const eventEmitter = new AgentEventEmitter(); @@ -2968,7 +2968,7 @@ describe('subagent.ts', () => { { text: 'Internal reasoning here.' as string, thought: true }, { text: 'The final answer.' as string }, ]); - vi.mocked(GeminiChat).mockImplementation( + vi.mocked(LlmChat).mockImplementation( () => ({ sendMessageStream: mockSendMessageStream, @@ -2976,7 +2976,7 @@ describe('subagent.ts', () => { getHistoryToolCallFingerprints: vi.fn( () => new Map(), ), - }) as unknown as GeminiChat, + }) as unknown as LlmChat, ); const scope = await AgentHeadless.create( @@ -3036,7 +3036,7 @@ describe('subagent.ts', () => { } })(); }); - vi.mocked(GeminiChat).mockImplementation( + vi.mocked(LlmChat).mockImplementation( () => ({ sendMessageStream: mockSendMessageStream, @@ -3044,7 +3044,7 @@ describe('subagent.ts', () => { getHistoryToolCallFingerprints: vi.fn( () => new Map(), ), - }) as unknown as GeminiChat, + }) as unknown as LlmChat, ); const scope = await AgentHeadless.create( @@ -3479,10 +3479,10 @@ describe('subagent.ts', () => { await scope.execute(new ContextState()); const retryArg = loopSpy.mock.calls.find( - ([event]) => event.type === GeminiEventType.Retry, - )?.[0] as { type: GeminiEventType; isContinuation?: boolean }; + ([event]) => event.type === LlmEventType.Retry, + )?.[0] as { type: LlmEventType; isContinuation?: boolean }; expect(retryArg).toEqual( - expect.objectContaining({ type: GeminiEventType.Retry }), + expect.objectContaining({ type: LlmEventType.Retry }), ); if ('isContinuation' in retry) { expect(retryArg.isContinuation).toBe(true); diff --git a/packages/core/src/agents/runtime/agent-headless.ts b/packages/core/src/agents/runtime/agent-headless.ts index 2870d4bc7be..7447ae878d5 100644 --- a/packages/core/src/agents/runtime/agent-headless.ts +++ b/packages/core/src/agents/runtime/agent-headless.ts @@ -16,7 +16,7 @@ import type { Content, FunctionDeclaration } from '@google/genai'; import type { Config } from '../../config/config.js'; -import type { GeminiChat } from '../../core/geminiChat.js'; +import type { LlmChat } from '../../core/llm-chat.js'; import type { RuntimeContentGeneratorView } from './agent-context.js'; import { createChildAbortController } from '../../utils/abortController.js'; import { createDebugLogger } from '../../utils/debugLogger.js'; @@ -139,7 +139,7 @@ export class AgentHeadless { private readonly core: AgentCore; private finalText: string = ''; private terminateMode: AgentTerminateMode = AgentTerminateMode.ERROR; - private chat?: GeminiChat; + private chat?: LlmChat; private toolsList?: FunctionDeclaration[]; private executing = false; private hasStartedReasoning = false; diff --git a/packages/core/src/agents/runtime/agent-interactive.ts b/packages/core/src/agents/runtime/agent-interactive.ts index 0fdb8014989..abf94cd6c9d 100644 --- a/packages/core/src/agents/runtime/agent-interactive.ts +++ b/packages/core/src/agents/runtime/agent-interactive.ts @@ -26,7 +26,7 @@ import type { import type { AgentStatsSummary } from './agent-statistics.js'; import type { AgentCore } from './agent-core.js'; import type { ContextState } from './agent-headless.js'; -import type { GeminiChat } from '../../core/geminiChat.js'; +import type { LlmChat } from '../../core/llm-chat.js'; import type { FunctionDeclaration } from '@google/genai'; import { ToolConfirmationOutcome, @@ -76,7 +76,7 @@ export class AgentInteractive { private executionPromise: Promise | undefined; private masterAbortController = createAbortController(); private roundAbortController: AbortController | undefined; - private chat: GeminiChat | undefined; + private chat: LlmChat | undefined; private toolsList: FunctionDeclaration[] = []; private processing = false; private roundCancelledByUser = false; diff --git a/packages/core/src/agents/runtime/workflow-stall.ts b/packages/core/src/agents/runtime/workflow-stall.ts index adf75ebd9b0..5a03602b79d 100644 --- a/packages/core/src/agents/runtime/workflow-stall.ts +++ b/packages/core/src/agents/runtime/workflow-stall.ts @@ -47,7 +47,7 @@ import { parsePositiveIntegerEnv } from '../../utils/env.js'; * Sized against the `retryWithBackoff` silent retry ladder rather than against * a guess at model latency. That ladder is the binding case, not the only * watchdog-invisible wait: stream-side rate-limit sleeps - * (`RATE_LIMIT_RETRY_OPTIONS` in geminiChat.ts — 60s/120s/240s/300s, so two + * (`RATE_LIMIT_RETRY_OPTIONS` in llm-chat.ts — 60s/120s/240s/300s, so two * consecutive sleeps already reach 180s), a provider `Retry-After` honored * unclamped on the normal HTTP path, and unattended-mode persistent backoff * (up to 5 min per exponential sleep — but a provider `Retry-After` on that diff --git a/packages/core/src/config/config-session-env.test.ts b/packages/core/src/config/config-session-env.test.ts index a9e596a9c21..b65c6f08036 100644 --- a/packages/core/src/config/config-session-env.test.ts +++ b/packages/core/src/config/config-session-env.test.ts @@ -454,7 +454,7 @@ describe('Config.getModelRouteIdentity (#9454 route key)', () => { const config = new Config({ ...baseParams }); await config.refreshAuth(AuthType.USE_GEMINI); - // Route-scoped caches (e.g. GeminiChat token counts) compare these + // Route-scoped caches (e.g. LlmChat token counts) compare these // strings for equality — the value must not drift between calls. const first = config.getModelRouteIdentity(); expect(config.getModelRouteIdentity()).toBe(first); @@ -486,11 +486,13 @@ describe('Config.getModelRouteIdentity (#9454 route key)', () => { baseUrl: 'https://route.example/v1', } as ContentGeneratorConfig); expect(explicit).toMatch(/^route-model@[0-9a-f]{8}$/); - expect(config.getModelRouteIdentity('route-model', { - model: 'route-model', - authType: 'openai', - baseUrl: 'https://route.example/v1', - } as ContentGeneratorConfig)).toBe(explicit); + expect( + config.getModelRouteIdentity('route-model', { + model: 'route-model', + authType: 'openai', + baseUrl: 'https://route.example/v1', + } as ContentGeneratorConfig), + ).toBe(explicit); }); it('does not mix the registry base URL into a non-active model identity', async () => { @@ -523,13 +525,17 @@ describe('Config.getModelRouteIdentity (#9454 route key)', () => { registrySpy.mockReturnValue('https://registry.example/v1'); const activeWithRegistry = config.getModelRouteIdentity(); - const foreignWithRegistry = - config.getModelRouteIdentity('foreign-model', foreignGeneratorConfig); + const foreignWithRegistry = config.getModelRouteIdentity( + 'foreign-model', + foreignGeneratorConfig, + ); registrySpy.mockReturnValue(null); const activeWithoutRegistry = config.getModelRouteIdentity(); - const foreignWithoutRegistry = - config.getModelRouteIdentity('foreign-model', foreignGeneratorConfig); + const foreignWithoutRegistry = config.getModelRouteIdentity( + 'foreign-model', + foreignGeneratorConfig, + ); // The fallback is load-bearing for the ACTIVE model… expect(activeWithRegistry).toMatch(/^active-model@[0-9a-f]{8}$/); diff --git a/packages/core/src/config/config.safe-mode.test.ts b/packages/core/src/config/config.safe-mode.test.ts index cf9524f8cdf..2e814db5a67 100644 --- a/packages/core/src/config/config.safe-mode.test.ts +++ b/packages/core/src/config/config.safe-mode.test.ts @@ -137,9 +137,9 @@ vi.mock('../core/contentGenerator.js', () => ({ })); vi.mock('../core/client.js', () => { - const GeminiClientMock = vi.fn(); - GeminiClientMock.prototype.initialize = vi.fn().mockResolvedValue(undefined); - return { GeminiClient: GeminiClientMock }; + const LlmClientMock = vi.fn(); + LlmClientMock.prototype.initialize = vi.fn().mockResolvedValue(undefined); + return { LlmClient: LlmClientMock }; }); vi.mock('../telemetry/index.js', () => ({ diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 6ab4d076290..8bd1bb8cfa0 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -52,7 +52,7 @@ import { resolveContentGeneratorConfigWithSources, } from '../core/contentGenerator.js'; import { DEFAULT_TOKEN_LIMIT } from '../core/tokenLimits.js'; -import { GeminiClient } from '../core/client.js'; +import { LlmClient } from '../core/client.js'; import { ShellTool } from '../tools/shell.js'; import { canUseRipgrep } from '../utils/ripgrepUtils.js'; import { getSessionProjectDir } from '../utils/sessionIdContext.js'; @@ -306,7 +306,7 @@ vi.mock('../tools/memory-config', () => ({ vi.mock('../core/contentGenerator.js'); vi.mock('../core/client.js', () => ({ - GeminiClient: vi.fn().mockImplementation(() => ({ + LlmClient: vi.fn().mockImplementation(() => ({ initialize: vi.fn().mockResolvedValue(undefined), isInitialized: vi.fn().mockReturnValue(true), setTools: vi.fn(), @@ -572,7 +572,7 @@ describe('Server Config (config.ts)', () => { const config = new Config({ ...baseParams, sessionId }); expect(getSessionProjectDir(sessionId)).toBeUndefined(); await config.initialize({ - skipGeminiInitialization: true, + skipLlmInitialization: true, skipHooks: true, skipMcpDiscovery: true, skipSkillManager: true, @@ -583,6 +583,20 @@ describe('Server Config (config.ts)', () => { // In daemon mode this is what stops the map growing per session. expect(getSessionProjectDir(sessionId)).toBeUndefined(); }); + + it('accepts the deprecated Gemini initialization option', async () => { + const config = new Config(baseParams); + await config.initialize({ + skipGeminiInitialization: true, + skipHooks: true, + skipMcpDiscovery: true, + skipSkillManager: true, + skipFileCheckpointing: true, + }); + + expect(config.getGeminiClient()).toBe(config.getLlmClient()); + await config.shutdown(); + }); }); describe('shell execution config', () => { @@ -2330,7 +2344,7 @@ describe('Server Config (config.ts)', () => { describe('MemoryPressureMonitor isolation', () => { it('returns a distinct monitor for child Configs created via deriveConfig', async () => { const parent = new Config(baseParams); - await parent.initialize({ skipGeminiInitialization: true }); + await parent.initialize({ skipLlmInitialization: true }); const child = deriveConfig(parent); const parentMonitor = parent.getMemoryPressureMonitor(); @@ -2344,7 +2358,7 @@ describe('Server Config (config.ts)', () => { it('resets monitor cleanup state when starting a new session', async () => { const config = new Config(baseParams); - await config.initialize({ skipGeminiInitialization: true }); + await config.initialize({ skipLlmInitialization: true }); const monitor = config.getMemoryPressureMonitor(); expect(monitor).toBeDefined(); const resetSpy = vi.spyOn(monitor!, 'resetForNewSession'); @@ -2406,7 +2420,7 @@ describe('Server Config (config.ts)', () => { process.env['QWEN_MEMORY_PRESSURE_CRITICAL'] = '0.9'; const config = new Config(baseParams); - await config.initialize({ skipGeminiInitialization: true }); + await config.initialize({ skipLlmInitialization: true }); mockMemoryRatio(0.35); expect(config.getMemoryPressureMonitor()?.getPressureLevel()).toBe( @@ -2421,7 +2435,7 @@ describe('Server Config (config.ts)', () => { process.env['QWEN_MEMORY_PRESSURE_CRITICAL'] = '0.9'; const config = new Config(baseParams); - await config.initialize({ skipGeminiInitialization: true }); + await config.initialize({ skipLlmInitialization: true }); mockMemoryRatio(0.35); expect(config.getMemoryPressureMonitor()?.getPressureLevel()).toBe( @@ -2437,7 +2451,7 @@ describe('Server Config (config.ts)', () => { process.env['QWEN_MEMORY_PRESSURE_SOFT'] = '0.7'; const config = new Config(baseParams); - await config.initialize({ skipGeminiInitialization: true }); + await config.initialize({ skipLlmInitialization: true }); expect(config.getMemoryPressureMonitor()).toBeDefined(); expect(stderrSpy).toHaveBeenCalledWith( @@ -2454,7 +2468,7 @@ describe('Server Config (config.ts)', () => { process.env['QWEN_MEMORY_PRESSURE_SOFT'] = value; const config = new Config(baseParams); - await config.initialize({ skipGeminiInitialization: true }); + await config.initialize({ skipLlmInitialization: true }); mockMemoryRatio(0.35); expect(config.getMemoryPressureMonitor()?.getPressureLevel()).toBe( @@ -2486,7 +2500,7 @@ describe('Server Config (config.ts)', () => { }); const config = new Config(baseParams); - await config.initialize({ skipGeminiInitialization: true }); + await config.initialize({ skipLlmInitialization: true }); mockMemoryRatio(0.85); config.getMemoryPressureMonitor()?.performCheck(); @@ -2503,7 +2517,7 @@ describe('Server Config (config.ts)', () => { process.env['QWEN_MEMORY_PRESSURE_HARD'] = '0.6'; process.env['QWEN_MEMORY_PRESSURE_CRITICAL'] = '0.9'; const parent = new Config(baseParams); - await parent.initialize({ skipGeminiInitialization: true }); + await parent.initialize({ skipLlmInitialization: true }); process.env['QWEN_MEMORY_PRESSURE_SOFT'] = '0.9'; process.env['QWEN_MEMORY_PRESSURE_HARD'] = '0.95'; @@ -2520,7 +2534,7 @@ describe('Server Config (config.ts)', () => { const sessionId = 'same-session-id'; const config = new Config({ ...baseParams, sessionId }); await config.initialize({ - skipGeminiInitialization: true, + skipLlmInitialization: true, skipHooks: true, skipMcpDiscovery: true, skipSkillManager: true, @@ -2544,7 +2558,7 @@ describe('Server Config (config.ts)', () => { it('pins the outgoing chat recorder to the outgoing session id', async () => { const config = new Config({ ...baseParams, chatRecording: true }); await config.initialize({ - skipGeminiInitialization: true, + skipLlmInitialization: true, skipHooks: true, skipMcpDiscovery: true, skipSkillManager: true, @@ -2564,7 +2578,7 @@ describe('Server Config (config.ts)', () => { it('ends the outgoing session before starting a replacement without continuation', async () => { const config = new Config({ ...baseParams }); await config.initialize({ - skipGeminiInitialization: true, + skipLlmInitialization: true, skipHooks: true, skipMcpDiscovery: true, skipSkillManager: true, @@ -2594,7 +2608,7 @@ describe('Server Config (config.ts)', () => { it('carries the outgoing session id when resuming a different persisted session', async () => { const config = new Config({ ...baseParams }); await config.initialize({ - skipGeminiInitialization: true, + skipLlmInitialization: true, skipHooks: true, skipMcpDiscovery: true, skipSkillManager: true, @@ -5244,7 +5258,7 @@ describe('Server Config (config.ts)', () => { ); // Verify that contentGeneratorConfig is updated expect(config.getContentGeneratorConfig()).toEqual(mockContentConfig); - expect(GeminiClient).toHaveBeenCalledWith(config); + expect(LlmClient).toHaveBeenCalledWith(config); }); it('preserves the user reasoning effort across an auth refresh that wipes it', async () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index f283a83b86e..994fe3a599c 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -35,7 +35,7 @@ import type { TeamContext } from '../agents/team/types.js'; // Core import { BaseLlmClient } from '../core/baseLlmClient.js'; -import { GeminiClient } from '../core/client.js'; +import { LlmClient } from '../core/client.js'; import { resolveInteractionMode } from '../core/prompts.js'; import type { OutputStyleDefinition } from '../core/output-styles.js'; import { @@ -1597,9 +1597,11 @@ export interface ConfigInitializeOptions { */ sendSdkMcpMessage?: SendSdkMcpMessage; /** - * Skip Gemini client chat initialization. Useful for bootstrap paths that + * Skip LLM client chat initialization. Useful for bootstrap paths that * need config services (hooks, tools, MCP) before a real session exists. */ + skipLlmInitialization?: boolean; + /** @deprecated Use `skipLlmInitialization`; retained until a future major release. */ skipGeminiInitialization?: boolean; /** * skip MCP @@ -1873,7 +1875,7 @@ export class Config { * headless) reads it via {@link consumePendingStartupWorktreeNotice} on * the model's first prompt and skips Phase C's `restoreWorktreeContext` * for that turn — startup wins over the resumed-session sidecar. ACP is - * gated out earlier in `gemini.tsx` (mutex with `--worktree`) so it + * gated out earlier in `llm.tsx` (mutex with `--worktree`) so it * never reaches this slot. * * @invariant At most one consumer per process. If a future entry path @@ -2020,7 +2022,7 @@ export class Config { private userMemory: string; /** * The cross-session-stable prefix of the main-session system prompt — - * the stable → context layers `GeminiClient.getMainSessionSystemInstruction()` + * the stable → context layers `LlmClient.getMainSessionSystemInstruction()` * assembles before the volatile tails (git status, auto-memory). Recorded * so the Anthropic converter can place an early cache breakpoint on the * stable prefix; consumers match it via `startsWith` and fail open to the @@ -2063,7 +2065,7 @@ export class Config { private activeTodoReminders = new Map(); private activeTodoWorkChainOwners = new Map(); private activeTodoReminderTurns = new Map(); - private geminiClient!: GeminiClient; + private llmClient!: LlmClient; private baseLlmClient!: BaseLlmClient; private cronScheduler: CronScheduler | null = null; private readonly fileFiltering: { @@ -2129,7 +2131,7 @@ export class Config { /** * startChat orphan-repair preserve. Defaults to `restoreAskUserQuestion`. * A load/resume that will not re-hang (no client, fork) turns this off so - * Gemini history is repaired in lockstep with replay finalization. + * LLM history is repaired in lockstep with replay finalization. */ private preserveRestorableAskUserQuestion = false; private readonly sessionWriterLeaseEnabled: boolean = false; @@ -2659,7 +2661,7 @@ export class Config { // before initialize() awaits (and surfaces) the stored promise. this.proxyDispatcherReady.catch(() => {}); } - this.geminiClient = new GeminiClient(this); + this.llmClient = new LlmClient(this); this.chatRecordingService = this.chatRecordingEnabled ? this.createChatRecordingService() : undefined; @@ -3173,11 +3175,13 @@ export class Config { `Tool registry initialized with ${this.toolRegistry.getAllToolNames().length} tools`, ); - if (!options?.skipGeminiInitialization) { - await this.geminiClient.initialize(); - this.debugLogger.info('Gemini client initialized'); + if ( + !(options?.skipLlmInitialization ?? options?.skipGeminiInitialization) + ) { + await this.llmClient.initialize(); + this.debugLogger.info('LLM client initialized'); } else { - this.debugLogger.info('Gemini client initialization skipped'); + this.debugLogger.info('LLM client initialization skipped'); } // Detect and capture runtime model snapshot (from CLI/ENV/credentials) @@ -3479,18 +3483,18 @@ export class Config { .discoverAllMcpToolsIncremental(this) .then(async () => { // After background discovery completes, push the newly-registered - // MCP tools into the active GeminiChat so the next model request + // MCP tools into the active LlmChat so the next model request // sees both the updated declarations and added-tool reminder deltas. // Interactive mode also calls setTools() via AppContainer's // batch-flush effect — this trailing call is idempotent there, but // it's the ONLY path that updates `chat.tools` for non-interactive // runs (no AppContainer). // Without this, `chat.tools` would be frozen at the built-in-only - // snapshot taken inside `geminiClient.initialize()` → `startChat()`, + // snapshot taken inside `llmClient.initialize()` → `startChat()`, // and `runNonInteractive` / stream-json / ACP would silently lose // progressive MCP tools — a regression vs the legacy synchronous path. try { - await this.geminiClient?.setTools(); + await this.llmClient?.setTools(); } catch (err) { this.debugLogger.error( `setTools() after background MCP discovery failed: ${err instanceof Error ? err.message : String(err)}`, @@ -4492,7 +4496,7 @@ export class Config { /** * Identity of the currently active model route for consumers that cache * route-specific state and must invalidate it when a model/auth/endpoint - * switch swaps the content generator — e.g. GeminiChat's API-reported + * switch swaps the content generator — e.g. LlmChat's API-reported * token counts (#9454). Same identity ⇒ same serialization target. */ getModelRouteIdentity( @@ -5379,7 +5383,7 @@ export class Config { /** * Stashes a one-shot context message that the next user prompt will * inject into the model (see {@link pendingStartupWorktreeNotice}). Called - * from `gemini.tsx` right after `loadCliConfig` when `--worktree` produced + * from `llm.tsx` right after `loadCliConfig` when `--worktree` produced * a valid worktree. Pass `null` to clear (rarely needed). */ setPendingStartupWorktreeNotice(notice: string | null): void { @@ -5617,7 +5621,7 @@ export class Config { /** * Swaps the active output style. Callers that change it mid-session must - * follow up with `GeminiClient.refreshSystemInstruction()`, since the style + * follow up with `LlmClient.refreshSystemInstruction()`, since the style * lives in the stable layer of an already-bound system instruction. */ setOutputStyle(style: OutputStyleDefinition | undefined): void { @@ -6941,8 +6945,13 @@ export class Config { return this.gitCoAuthor; } - getGeminiClient(): GeminiClient { - return this.geminiClient; + getLlmClient(): LlmClient { + return this.llmClient; + } + + /** @deprecated Use `getLlmClient`; retained until a future major release. */ + getGeminiClient(): LlmClient { + return this.getLlmClient(); } private getOwnActiveTodoReminders(): Map { @@ -7383,7 +7392,7 @@ export class Config { return this.preserveRestorableAskUserQuestion; } - /** Load/resume declined the re-hang: repair Gemini history like flag-off. */ + /** Load/resume declined the re-hang: repair LLM history like flag-off. */ suppressRestorableAskUserQuestionPreservation(): void { this.preserveRestorableAskUserQuestion = false; } @@ -8537,7 +8546,7 @@ export class Config { * client's `drainSkillAndCommandReminders` consumes these to mark them as * announced and avoid a duplicate announcement in the same turn's tail * reminder. Keys use the `"skill:"` format matching - * `GeminiClient.skillEntryKey`. + * `LlmClient.skillEntryKey`. */ addInlineAnnouncedSkillKeys(keys: Iterable): void { for (const k of keys) { diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index 6f8408be1d4..b792f66dddf 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -957,7 +957,7 @@ describe('AnthropicContentGenerator', () => { }); it('splits the system prompt at the Config-recorded static prefix (4-breakpoint layout)', async () => { - // End-to-end through the generator: `GeminiClient` records the + // End-to-end through the generator: `LlmClient` records the // gitStatus-free base on Config, the generator reads it per request, // and the converter splits the system prompt there — static prefix // carries scope:'global' (cross-session reuse), volatile suffix stays @@ -1409,7 +1409,7 @@ describe('AnthropicContentGenerator', () => { const convertResponseSpy = vi .spyOn( AnthropicContentConverter.prototype, - 'convertAnthropicResponseToGemini', + 'convertAnthropicResponseToLlm', ) .mockReturnValue( (() => { diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index 1c6d0887795..b9a07a0fd3c 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -385,7 +385,7 @@ export class AnthropicContentGenerator implements ContentGenerator { perRequestAc?.abort(); } - return this.converter.convertAnthropicResponseToGemini(response); + return this.converter.convertAnthropicResponseToLlm(response); } async generateContentStream( @@ -746,7 +746,7 @@ export class AnthropicContentGenerator implements ContentGenerator { const cacheRetentionByBlock = this.contentGeneratorConfig.cacheRetentionByBlock; - const { system, messages } = this.converter.convertGeminiRequestToAnthropic( + const { system, messages } = this.converter.convertLlmRequestToAnthropic( request, { // DeepSeek normalization and injection run together. Proxy-hosted @@ -771,15 +771,12 @@ export class AnthropicContentGenerator implements ContentGenerator { ); const tools = request.config?.tools - ? await this.converter.convertGeminiToolsToAnthropic( - request.config.tools, - { - enableCacheControl, - useGlobalCacheScope, - cacheRetention, - cacheRetentionByBlock, - }, - ) + ? await this.converter.convertLlmToolsToAnthropic(request.config.tools, { + enableCacheControl, + useGlobalCacheScope, + cacheRetention, + cacheRetentionByBlock, + }) : undefined; // Map Gemini-style toolConfig.functionCallingConfig.mode to Anthropic's @@ -1293,7 +1290,7 @@ export class AnthropicContentGenerator implements ContentGenerator { typeof name === 'string' && name.length > 0 ) { - const chunk = this.buildGeminiChunk( + const chunk = this.buildLlmChunk( undefined, messageId, model, @@ -1314,7 +1311,7 @@ export class AnthropicContentGenerator implements ContentGenerator { if (deltaType === 'text_delta') { const text = 'text' in event.delta ? event.delta.text : ''; if (text) { - const chunk = this.buildGeminiChunk( + const chunk = this.buildLlmChunk( { text }, messageId, model, @@ -1328,7 +1325,7 @@ export class AnthropicContentGenerator implements ContentGenerator { const thinking = (event.delta as { thinking?: string }).thinking || ''; if (thinking) { - const chunk = this.buildGeminiChunk( + const chunk = this.buildLlmChunk( { text: thinking, thought: true }, messageId, model, @@ -1343,7 +1340,7 @@ export class AnthropicContentGenerator implements ContentGenerator { (event.delta as { signature?: string }).signature || ''; if (signature) { blockState.signature += signature; - const chunk = this.buildGeminiChunk( + const chunk = this.buildLlmChunk( { thought: true, thoughtSignature: signature }, messageId, model, @@ -1377,7 +1374,7 @@ export class AnthropicContentGenerator implements ContentGenerator { hasNonObjectToolCall = true; } } else { - const chunk = this.buildGeminiChunk( + const chunk = this.buildLlmChunk( { functionCall: { id: blockState.id, @@ -1483,7 +1480,7 @@ export class AnthropicContentGenerator implements ContentGenerator { if (finishReason || event.usage) { messageStartUsagePending = false; - const chunk = this.buildGeminiChunk( + const chunk = this.buildLlmChunk( undefined, messageId, model, @@ -1512,7 +1509,7 @@ export class AnthropicContentGenerator implements ContentGenerator { cacheCreationTokensReported ) { messageStartUsagePending = false; - const chunk = this.buildGeminiChunk( + const chunk = this.buildLlmChunk( undefined, messageId, model, @@ -1544,7 +1541,7 @@ export class AnthropicContentGenerator implements ContentGenerator { if (upstreamStreamFailed) { const upstreamErrorClassification = classifyRetryError(upstreamStreamError); - // Match GeminiChat's replay boundary: only known mid-SSE socket cuts + // Match LlmChat's replay boundary: only known mid-SSE socket cuts // may release an already closed batch before the error is propagated. if ( isRetryableStreamTransportError(upstreamErrorClassification) && @@ -1628,13 +1625,13 @@ export class AnthropicContentGenerator implements ContentGenerator { ...(headers ? { headers } : {}), })) as Message; reportAnthropicResponse(fallbackAttempt, response); - yield this.converter.convertAnthropicResponseToGemini(response); + yield this.converter.convertAnthropicResponseToLlm(response); } catch (error) { throw redactProxyError(error); } } - private buildGeminiChunk( + private buildLlmChunk( part?: { text?: string; thought?: boolean; @@ -1655,7 +1652,7 @@ export class AnthropicContentGenerator implements ContentGenerator { const candidateParts = part ? [part as unknown as Part] : []; const mappedFinishReason = finishReason !== undefined - ? this.converter.mapAnthropicFinishReasonToGemini(finishReason) + ? this.converter.mapAnthropicFinishReasonToLlm(finishReason) : undefined; response.candidates = [ { diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 92efbe74bfa..49ff2809228 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -26,9 +26,9 @@ describe('AnthropicContentConverter', () => { converter = new AnthropicContentConverter('test-model', 'auto'); }); - describe('convertGeminiRequestToAnthropic', () => { + describe('convertLlmRequestToAnthropic', () => { it('extracts systemInstruction text from string', () => { - const { system } = converter.convertGeminiRequestToAnthropic({ + const { system } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: 'hi', config: { systemInstruction: 'sys' }, @@ -44,7 +44,7 @@ describe('AnthropicContentConverter', () => { }); it('extracts systemInstruction text from parts and joins with newlines', () => { - const { system } = converter.convertGeminiRequestToAnthropic({ + const { system } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: 'hi', config: { @@ -70,7 +70,7 @@ describe('AnthropicContentConverter', () => { // cross-session caching under the `prompt-caching-scope-2026-01-05` // beta. Non-Anthropic backends pass false (or omit) so they see the // standard per-session shape verified by the test above. - const { system } = converter.convertGeminiRequestToAnthropic( + const { system } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: 'hi', @@ -94,7 +94,7 @@ describe('AnthropicContentConverter', () => { const fullSystem = staticPrefix + volatileSuffix; it('splits the system prompt at the static prefix boundary, scoping only the prefix', () => { - const { system } = converter.convertGeminiRequestToAnthropic( + const { system } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: 'hi', @@ -121,7 +121,7 @@ describe('AnthropicContentConverter', () => { }); it('splits without scope when useGlobalCacheScope is off', () => { - const { system } = converter.convertGeminiRequestToAnthropic( + const { system } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: 'hi', @@ -145,7 +145,7 @@ describe('AnthropicContentConverter', () => { }); it('falls back to a single block when the prefix does not match (subagent prompt)', () => { - const { system } = converter.convertGeminiRequestToAnthropic( + const { system } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: 'hi', @@ -166,7 +166,7 @@ describe('AnthropicContentConverter', () => { it('falls back to a single block when there is no suffix beyond the prefix', () => { // Not a git repo → the system prompt IS the static prefix. A split // would leave an empty second block, which Anthropic rejects. - const { system } = converter.convertGeminiRequestToAnthropic( + const { system } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: 'hi', @@ -186,7 +186,7 @@ describe('AnthropicContentConverter', () => { }); it('converts a plain string content into a user message', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: 'Hello', }); @@ -206,7 +206,7 @@ describe('AnthropicContentConverter', () => { }); it('converts user content parts into a user message with text blocks', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { @@ -232,7 +232,7 @@ describe('AnthropicContentConverter', () => { }); it('preserves ordered multi-part startup reminder user content', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { @@ -261,7 +261,7 @@ describe('AnthropicContentConverter', () => { }); it('converts assistant thought parts into Anthropic thinking blocks', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { @@ -286,7 +286,7 @@ describe('AnthropicContentConverter', () => { }); it('converts functionCall parts from model role into tool_use blocks', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { @@ -332,7 +332,7 @@ describe('AnthropicContentConverter', () => { }); it('normalizes legacy dotted MCP names before sending history', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { @@ -374,7 +374,7 @@ describe('AnthropicContentConverter', () => { }); it('converts functionResponse parts into user tool_result messages', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { @@ -418,7 +418,7 @@ describe('AnthropicContentConverter', () => { }); it('extracts function response error field when present', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { @@ -463,7 +463,7 @@ describe('AnthropicContentConverter', () => { }); it('creates tool result with empty content for empty function responses', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { @@ -509,7 +509,7 @@ describe('AnthropicContentConverter', () => { }); it('converts function response with inlineData image parts into tool_result with images', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { @@ -573,7 +573,7 @@ describe('AnthropicContentConverter', () => { it.each(['audio/mpeg', 'image/bmp'])( 'renders unsupported %s inlineData as a text block', (mimeType) => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { @@ -633,7 +633,7 @@ describe('AnthropicContentConverter', () => { ); it('converts inlineData with PDF into document block', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { @@ -695,7 +695,7 @@ describe('AnthropicContentConverter', () => { }); it('converts fileData with image into image url block', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { @@ -757,7 +757,7 @@ describe('AnthropicContentConverter', () => { }); it('converts fileData with PDF into document url block', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { @@ -819,7 +819,7 @@ describe('AnthropicContentConverter', () => { }); it('renders unsupported fileData as a text block', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { @@ -876,7 +876,7 @@ describe('AnthropicContentConverter', () => { }); it('associates each image with its preceding functionResponse', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { @@ -979,7 +979,7 @@ describe('AnthropicContentConverter', () => { }); it('merges consecutive assistant messages into one', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -1027,7 +1027,7 @@ describe('AnthropicContentConverter', () => { }); it('merges thinking blocks before non-thinking blocks', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -1074,7 +1074,7 @@ describe('AnthropicContentConverter', () => { // scanned and found lacking a matching tool_result -- not merely the // absence of any subsequent message (see the "trailing tool_use" // test below for that case). - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -1118,7 +1118,7 @@ describe('AnthropicContentConverter', () => { // sending the completed turn to Anthropic (token counting, a // resumed/replayed session snapshot, ...). Regression test for the // bug where this exact shape had its tool_use silently deleted. - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'What is the weather in Paris?' }] }, @@ -1157,7 +1157,7 @@ describe('AnthropicContentConverter', () => { // orphan, the signature no longer matches and replaying it 400s: // "thinking blocks in the latest assistant message cannot be // modified". So the thinking block must go with it. - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -1187,7 +1187,7 @@ describe('AnthropicContentConverter', () => { // still needed to satisfy Anthropic's manual-mode "final turn must // begin with thinking when a tool_use is present" rule, and // cascading here would trade one 400 for another. - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -1230,7 +1230,7 @@ describe('AnthropicContentConverter', () => { // part and an orphaned tool_use, so after the cascade strips both, // finalBlocks is empty and the whole assistant message must be // dropped -- and the surrounding user messages must merge. - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'before' }] }, @@ -1259,7 +1259,7 @@ describe('AnthropicContentConverter', () => { }); it('cleans orphaned tool_result blocks without matching tool_use', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -1304,7 +1304,7 @@ describe('AnthropicContentConverter', () => { // same tool_use_id ("each `tool_use` block must have a single // result" -- HTTP 400). This can happen when a tool call's result // is recorded twice in history. - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -1346,7 +1346,7 @@ describe('AnthropicContentConverter', () => { }); it('drops a duplicate tool_result for one id while keeping a different id in the same message', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -1409,7 +1409,7 @@ describe('AnthropicContentConverter', () => { // "String should match pattern '^[a-zA-Z0-9_-]+$'". it('sanitizes a tool_use id containing characters outside [a-zA-Z0-9_-]', () => { const rawId = 'call:abc.def/ghi?jkl'; - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -1454,7 +1454,7 @@ describe('AnthropicContentConverter', () => { }); it('generates a non-empty fallback id when functionCall.id is missing (not an empty string)', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -1484,7 +1484,7 @@ describe('AnthropicContentConverter', () => { // is already covered. it('does not collide fallback ids generated for two different missing-id tool calls in the same request', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -1511,7 +1511,7 @@ describe('AnthropicContentConverter', () => { it('resolves the same source id to the same sanitized id across tool_use and tool_result in different messages', () => { const rawId = 'weird/id:1'; - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -1550,7 +1550,7 @@ describe('AnthropicContentConverter', () => { }); it('keeps tool results split across consecutive user messages', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -1619,7 +1619,7 @@ describe('AnthropicContentConverter', () => { // same tool_use_id. Without a second dedup pass at the merge site, // the merged message would resurface the exact "two tool_result // blocks for one tool_use_id" shape Anthropic rejects. - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -1678,7 +1678,7 @@ describe('AnthropicContentConverter', () => { // the two most recently merged messages -- with three originally // separate user turns each carrying a tool_result for the same // tool_use_id, only the first should survive. - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -1740,7 +1740,7 @@ describe('AnthropicContentConverter', () => { }); it('merges users when dropping an orphan-only assistant turn', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'before' }] }, @@ -1768,7 +1768,7 @@ describe('AnthropicContentConverter', () => { }); it('keeps tool results before text when merging consecutive users', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -1818,7 +1818,7 @@ describe('AnthropicContentConverter', () => { // both the tool_result AND its paired tool_use, rather than fixing // the order. Now the blocks are reordered before that gate runs, so // the pairing is recognized and everything survives. - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -1863,7 +1863,7 @@ describe('AnthropicContentConverter', () => { }); it('preserves relative order among multiple tool_result blocks when reordering ahead of text', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -1910,7 +1910,7 @@ describe('AnthropicContentConverter', () => { }); it('deduplicates tool_use blocks by id during merge', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -1947,7 +1947,7 @@ describe('AnthropicContentConverter', () => { describe('unsigned proxy thinking history', () => { it('drops unsigned thinking while preserving visible content and signed blocks', () => { - const { messages } = converter.convertGeminiRequestToAnthropic( + const { messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -1992,7 +1992,7 @@ describe('AnthropicContentConverter', () => { }); it('drops a thinking-only turn and merges the surrounding user turns', () => { - const { messages } = converter.convertGeminiRequestToAnthropic( + const { messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -2023,7 +2023,7 @@ describe('AnthropicContentConverter', () => { it('fails locally when an unsigned thinking block belongs to a tool-use turn', () => { expect(() => - converter.convertGeminiRequestToAnthropic( + converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -2071,7 +2071,7 @@ describe('AnthropicContentConverter', () => { // must be on a NON-latest turn that is still part of the unbroken // tool_use/tool_result chain reaching the end of history. expect(() => - converter.convertGeminiRequestToAnthropic( + converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -2126,7 +2126,7 @@ describe('AnthropicContentConverter', () => { }); it('drops unsigned thinking from a completed tool-use turn', () => { - const { messages } = converter.convertGeminiRequestToAnthropic( + const { messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -2165,7 +2165,7 @@ describe('AnthropicContentConverter', () => { it('fails when an earlier step in the active tool loop has unsigned thinking', () => { expect(() => - converter.convertGeminiRequestToAnthropic( + converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -2230,7 +2230,7 @@ describe('AnthropicContentConverter', () => { // actually invalid. Only an empty-text thinking block is // unconditionally invalid regardless of tool_use presence; a // populated, signed thinking block is left exactly as-is. - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -2257,14 +2257,14 @@ describe('AnthropicContentConverter', () => { }); it('drops an empty redacted_thinking-derived turn entirely (defensive, no plaintext fallback)', () => { - // convertAnthropicResponseToGemini represents a redacted_thinking + // convertAnthropicResponseToLlm represents a redacted_thinking // block as `{ text: '', thought: true }` (its opaque `data` doesn't // survive the Gemini-Part round trip -- see that method's doc). When // this round-trips back through processContent it becomes an // empty-text `thinking` block on the wire, which this defensive // guard drops outright, dropping the whole message since nothing // else survives. - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -2290,7 +2290,7 @@ describe('AnthropicContentConverter', () => { // an actually-empty-text block (matching the title) rather than a // populated one, so this test would fail if the exemption were ever // narrowed to "non-empty-text latest turns only". - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -2327,7 +2327,7 @@ describe('AnthropicContentConverter', () => { // Verified against api.deepseek.com/anthropic: plain-text assistant // turns without thinking are accepted. Avoid bloating replay history // with synthetic blocks the API does not require. - const { messages } = converter.convertGeminiRequestToAnthropic( + const { messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -2345,7 +2345,7 @@ describe('AnthropicContentConverter', () => { }); it('injects an empty thinking block on tool-calling assistant turns missing one', () => { - const { messages } = converter.convertGeminiRequestToAnthropic( + const { messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -2394,7 +2394,7 @@ describe('AnthropicContentConverter', () => { }); it('preserves existing thinking blocks on tool-use assistant turns', () => { - const { messages } = converter.convertGeminiRequestToAnthropic( + const { messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -2437,7 +2437,7 @@ describe('AnthropicContentConverter', () => { }); it('does not modify user messages', () => { - const { messages } = converter.convertGeminiRequestToAnthropic( + const { messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [{ role: 'user', parts: [{ text: 'Hi' }] }], @@ -2456,7 +2456,7 @@ describe('AnthropicContentConverter', () => { }); it('does nothing when option is disabled (default)', () => { - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -2478,7 +2478,7 @@ describe('AnthropicContentConverter', () => { functionResponse: { id, name: 'tool', response: { output: 'ok' } }, }); - const { messages } = converter.convertGeminiRequestToAnthropic( + const { messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -2513,7 +2513,7 @@ describe('AnthropicContentConverter', () => { // `content: []`, which Anthropic API rejects, and dropping the message // would break user/assistant alternation. Keep the original blocks // instead — DeepSeek empirically tolerates the residual mismatch. - const { messages } = converter.convertGeminiRequestToAnthropic( + const { messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -2547,7 +2547,7 @@ describe('AnthropicContentConverter', () => { // parts but the side-query disables thinking. The converter must drop // those blocks so the outgoing request matches the absent top-level // `thinking` config. - const { messages } = converter.convertGeminiRequestToAnthropic( + const { messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -2599,7 +2599,7 @@ describe('AnthropicContentConverter', () => { }); it('strips thinking after consecutive assistant turns are merged', () => { - const { messages } = converter.convertGeminiRequestToAnthropic( + const { messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -2692,7 +2692,7 @@ describe('AnthropicContentConverter', () => { // `signature` field. The cleanup adds an empty signature in place; // because the normalized block now satisfies the requirement, Step 2 // does not prepend a synthetic. - const { messages } = converter.convertGeminiRequestToAnthropic( + const { messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -2733,7 +2733,7 @@ describe('AnthropicContentConverter', () => { it('preserves an existing compliant thinking block on a tool-use turn', () => { // A thinking block with a real `signature` field is fully compliant — // the injector must not duplicate it. - const { messages } = converter.convertGeminiRequestToAnthropic( + const { messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -2787,7 +2787,7 @@ describe('AnthropicContentConverter', () => { // signature. The cleanup adds an empty signature in place to make the // block spec-compliant while preserving the original thinking text. // No synthetic is prepended on a plain-text turn (no tool_use). - const { messages } = converter.convertGeminiRequestToAnthropic( + const { messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -2820,7 +2820,7 @@ describe('AnthropicContentConverter', () => { it('injects on mixed text+tool_use assistant turns missing thinking', () => { // Common shape: model says something, then calls a tool. With no // thinking, this is still a tool-use turn that needs the synthetic. - const { messages } = converter.convertGeminiRequestToAnthropic( + const { messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -2862,7 +2862,7 @@ describe('AnthropicContentConverter', () => { describe('assistant-turn prefill stripping', () => { it('drops a trailing empty assistant message when stripTrailingAssistantPrefill is set', () => { - const { messages } = converter.convertGeminiRequestToAnthropic( + const { messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -2884,7 +2884,7 @@ describe('AnthropicContentConverter', () => { }); it('appends a synthetic user turn when a trailing assistant message has real content', () => { - const { messages } = converter.convertGeminiRequestToAnthropic( + const { messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -2906,7 +2906,7 @@ describe('AnthropicContentConverter', () => { }); it('leaves a trailing user message untouched when stripTrailingAssistantPrefill is set', () => { - const { messages } = converter.convertGeminiRequestToAnthropic( + const { messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -2926,7 +2926,7 @@ describe('AnthropicContentConverter', () => { }); it('does not strip a trailing assistant message when the option is unset', () => { - const { messages } = converter.convertGeminiRequestToAnthropic( + const { messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -2951,7 +2951,7 @@ describe('AnthropicContentConverter', () => { // must be preserved rather than dropped as an "empty prefill" — // unlike an unanswered tool_use, thinking blocks are never treated // as orphans by the earlier merge/clean passes. - const { messages } = converter.convertGeminiRequestToAnthropic( + const { messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: [ @@ -2988,7 +2988,7 @@ describe('AnthropicContentConverter', () => { }); }); - describe('convertGeminiToolsToAnthropic', () => { + describe('convertLlmToolsToAnthropic', () => { it('converts Tool.functionDeclarations to Anthropic tools and runs schema conversion', async () => { const tools = [ { @@ -3006,7 +3006,7 @@ describe('AnthropicContentConverter', () => { }, ] as Tool[]; - const result = await converter.convertGeminiToolsToAnthropic(tools); + const result = await converter.convertLlmToolsToAnthropic(tools); expect(result).toHaveLength(1); expect(result[0]).toEqual({ @@ -3036,7 +3036,7 @@ describe('AnthropicContentConverter', () => { }, ] as Tool[]; - const result = await converter.convertGeminiToolsToAnthropic(tools, { + const result = await converter.convertLlmToolsToAnthropic(tools, { useGlobalCacheScope: true, }); @@ -3062,7 +3062,7 @@ describe('AnthropicContentConverter', () => { }, ] as CallableTool[]; - const result = await converter.convertGeminiToolsToAnthropic(callable); + const result = await converter.convertLlmToolsToAnthropic(callable); expect(result).toHaveLength(1); expect(result[0].name).toBe('dynamic_tool'); @@ -3077,7 +3077,7 @@ describe('AnthropicContentConverter', () => { }, ] as Tool[]; - const result = await converter.convertGeminiToolsToAnthropic(tools); + const result = await converter.convertLlmToolsToAnthropic(tools); expect(result).toHaveLength(1); expect(result[0]).toEqual({ @@ -3104,7 +3104,7 @@ describe('AnthropicContentConverter', () => { }, ] as Tool[]; - const result = await converter.convertGeminiToolsToAnthropic(tools); + const result = await converter.convertLlmToolsToAnthropic(tools); expect(result[0]?.input_schema?.type).toBe('object'); }); @@ -3132,7 +3132,7 @@ describe('AnthropicContentConverter', () => { }, ] as Tool[]; - const result = await converter.convertGeminiToolsToAnthropic(tools); + const result = await converter.convertLlmToolsToAnthropic(tools); expect(result).toHaveLength(1); expect(result[0].name).toBe('valid_tool'); @@ -3158,16 +3158,16 @@ describe('AnthropicContentConverter', () => { }, ] as Tool[]; - const result = await converter.convertGeminiToolsToAnthropic(tools); + const result = await converter.convertLlmToolsToAnthropic(tools); expect(result).toHaveLength(1); expect(result[0].name).toBe('valid_tool'); }); }); - describe('convertAnthropicResponseToGemini', () => { + describe('convertAnthropicResponseToLlm', () => { it('converts text, tool_use, thinking, and redacted_thinking blocks', () => { - const response = converter.convertAnthropicResponseToGemini({ + const response = converter.convertAnthropicResponseToLlm({ id: 'msg-1', model: 'claude-test', stop_reason: 'end_turn', @@ -3204,7 +3204,7 @@ describe('AnthropicContentConverter', () => { }); it('handles tool_use input that is a JSON string', () => { - const response = converter.convertAnthropicResponseToGemini({ + const response = converter.convertAnthropicResponseToLlm({ id: 'msg-1', model: 'claude-test', stop_reason: null, @@ -3227,7 +3227,7 @@ describe('AnthropicContentConverter', () => { // converter must forward both cache fields so the normalizer can sum // them — dropping either silently undercounts the Footer reading by // the size of the dropped bucket. - const response = converter.convertAnthropicResponseToGemini({ + const response = converter.convertAnthropicResponseToLlm({ id: 'msg-1', model: 'claude-test', stop_reason: 'end_turn', @@ -3253,7 +3253,7 @@ describe('AnthropicContentConverter', () => { }); it('does not substitute the request model when the provider omits its model', () => { - const response = converter.convertAnthropicResponseToGemini({ + const response = converter.convertAnthropicResponseToLlm({ id: 'msg-no-model', model: '', stop_reason: 'end_turn', @@ -3265,15 +3265,15 @@ describe('AnthropicContentConverter', () => { }); }); - describe('mapAnthropicFinishReasonToGemini', () => { + describe('mapAnthropicFinishReasonToLlm', () => { it('maps known reasons', () => { - expect(converter.mapAnthropicFinishReasonToGemini('end_turn')).toBe( + expect(converter.mapAnthropicFinishReasonToLlm('end_turn')).toBe( FinishReason.STOP, ); - expect(converter.mapAnthropicFinishReasonToGemini('max_tokens')).toBe( + expect(converter.mapAnthropicFinishReasonToLlm('max_tokens')).toBe( FinishReason.MAX_TOKENS, ); - expect(converter.mapAnthropicFinishReasonToGemini('content_filter')).toBe( + expect(converter.mapAnthropicFinishReasonToLlm('content_filter')).toBe( FinishReason.SAFETY, ); }); @@ -3281,17 +3281,17 @@ describe('AnthropicContentConverter', () => { it('maps refusal into the content-filter family (#9026)', () => { // A refusal stop_reason is a provider safety decision. It must map // to SAFETY so the quiet post-tool-result acceptance gate in - // geminiChat keeps it fatal; falling through to + // llmChat keeps it fatal; falling through to // FINISH_REASON_UNSPECIFIED would let an armed attempt accept the // refusal as a quiet "(empty content)" completion. - expect(converter.mapAnthropicFinishReasonToGemini('refusal')).toBe( + expect(converter.mapAnthropicFinishReasonToLlm('refusal')).toBe( FinishReason.SAFETY, ); }); it('returns undefined for null/empty', () => { - expect(converter.mapAnthropicFinishReasonToGemini(null)).toBeUndefined(); - expect(converter.mapAnthropicFinishReasonToGemini('')).toBeUndefined(); + expect(converter.mapAnthropicFinishReasonToLlm(null)).toBeUndefined(); + expect(converter.mapAnthropicFinishReasonToLlm('')).toBeUndefined(); }); }); @@ -3302,7 +3302,7 @@ describe('AnthropicContentConverter', () => { 'auto', false, ); - const { system } = noCacheConverter.convertGeminiRequestToAnthropic({ + const { system } = noCacheConverter.convertLlmRequestToAnthropic({ model: 'models/test', contents: 'hi', config: { systemInstruction: 'sys' }, @@ -3317,7 +3317,7 @@ describe('AnthropicContentConverter', () => { 'auto', false, ); - const { messages } = noCacheConverter.convertGeminiRequestToAnthropic({ + const { messages } = noCacheConverter.convertLlmRequestToAnthropic({ model: 'models/test', contents: 'Hello', }); @@ -3337,7 +3337,7 @@ describe('AnthropicContentConverter', () => { // breakpoint from turn 2 onward and collapsed the cacheable region // back to system+tools. Anthropic docs explicitly list tool_result // as a cacheable block type in messages.content. - const { messages } = converter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'do the thing' }] }, @@ -3393,8 +3393,7 @@ describe('AnthropicContentConverter', () => { }, ] as Tool[]; - const result = - await noCacheConverter.convertGeminiToolsToAnthropic(tools); + const result = await noCacheConverter.convertLlmToolsToAnthropic(tools); expect(result).toHaveLength(1); expect(result[0]).toEqual({ @@ -3431,7 +3430,7 @@ describe('AnthropicContentConverter', () => { ); const { system, messages } = - constructedWithCacheOff.convertGeminiRequestToAnthropic( + constructedWithCacheOff.convertLlmRequestToAnthropic( { model: 'models/test', contents: 'Hello', @@ -3461,11 +3460,13 @@ describe('AnthropicContentConverter', () => { }, ]); - const result = - await constructedWithCacheOff.convertGeminiToolsToAnthropic(tools, { + const result = await constructedWithCacheOff.convertLlmToolsToAnthropic( + tools, + { enableCacheControl: true, useGlobalCacheScope: true, - }); + }, + ); expect(result[0].cache_control).toEqual({ type: 'ephemeral', scope: 'global', @@ -3483,7 +3484,7 @@ describe('AnthropicContentConverter', () => { ); const { system, messages } = - constructedWithCacheOn.convertGeminiRequestToAnthropic( + constructedWithCacheOn.convertLlmRequestToAnthropic( { model: 'models/test', contents: 'Hello', @@ -3497,10 +3498,12 @@ describe('AnthropicContentConverter', () => { { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, ]); - const result = - await constructedWithCacheOn.convertGeminiToolsToAnthropic(tools, { + const result = await constructedWithCacheOn.convertLlmToolsToAnthropic( + tools, + { enableCacheControl: false, - }); + }, + ); expect(result[0]).not.toHaveProperty('cache_control'); }); @@ -3512,7 +3515,7 @@ describe('AnthropicContentConverter', () => { 'test-model', 'auto', ); - const { system } = converterDefault.convertGeminiRequestToAnthropic( + const { system } = converterDefault.convertLlmRequestToAnthropic( { model: 'models/test', contents: 'Hello', @@ -3530,7 +3533,7 @@ describe('AnthropicContentConverter', () => { }, ]); - const result = await converterDefault.convertGeminiToolsToAnthropic( + const result = await converterDefault.convertLlmToolsToAnthropic( tools, { enableCacheControl: true }, ); @@ -3540,7 +3543,7 @@ describe('AnthropicContentConverter', () => { describe('cacheRetention', () => { it('omits ttl on the system block when cacheRetention is unset (ephemeral default)', () => { - const { system } = converter.convertGeminiRequestToAnthropic({ + const { system } = converter.convertLlmRequestToAnthropic({ model: 'models/test', contents: 'hi', config: { systemInstruction: 'sys' }, @@ -3551,7 +3554,7 @@ describe('AnthropicContentConverter', () => { }); it("sets ttl:'1h' on system, last tool, and trailing user message when cacheRetention is '1h'", async () => { - const { system, messages } = converter.convertGeminiRequestToAnthropic( + const { system, messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: 'hi', @@ -3574,7 +3577,7 @@ describe('AnthropicContentConverter', () => { cache_control: { type: 'ephemeral', ttl: '1h' }, }); - const tools = await converter.convertGeminiToolsToAnthropic( + const tools = await converter.convertLlmToolsToAnthropic( [ { functionDeclarations: [ @@ -3591,7 +3594,7 @@ describe('AnthropicContentConverter', () => { }); it('composes ttl with scope:"global" on the same cache_control entry', () => { - const { system } = converter.convertGeminiRequestToAnthropic( + const { system } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: 'hi', @@ -3619,7 +3622,7 @@ describe('AnthropicContentConverter', () => { // anchor ahead of a 1h system anchor -- an ordering violation. // resolveCacheRetention promotes every anchor before a '1h' one, // so the tool anchor here also resolves to '1h'. - const { system } = converter.convertGeminiRequestToAnthropic( + const { system } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: 'hi', @@ -3638,7 +3641,7 @@ describe('AnthropicContentConverter', () => { }, ]); - const tools = await converter.convertGeminiToolsToAnthropic( + const tools = await converter.convertLlmToolsToAnthropic( [ { functionDeclarations: [ @@ -3661,7 +3664,7 @@ describe('AnthropicContentConverter', () => { // tool -> system -> user.last is already longest-to-shortest here, // so nothing needs promoting; this is the one override shape that // was always legal even before the ordering fix. - const { system, messages } = converter.convertGeminiRequestToAnthropic( + const { system, messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: 'hi', @@ -3683,7 +3686,7 @@ describe('AnthropicContentConverter', () => { cache_control: { type: 'ephemeral' }, }); - const tools = await converter.convertGeminiToolsToAnthropic( + const tools = await converter.convertLlmToolsToAnthropic( [ { functionDeclarations: [ @@ -3707,7 +3710,7 @@ describe('AnthropicContentConverter', () => { // and system anchors at the 5m default ahead of a 1h trailing // user message -- also an ordering violation, and one the // reviewer's case analysis called out explicitly (case E). - const { system, messages } = converter.convertGeminiRequestToAnthropic( + const { system, messages } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: 'hi', @@ -3733,7 +3736,7 @@ describe('AnthropicContentConverter', () => { cache_control: { type: 'ephemeral', ttl: '1h' }, }); - const tools = await converter.convertGeminiToolsToAnthropic( + const tools = await converter.convertLlmToolsToAnthropic( [ { functionDeclarations: [ @@ -3753,7 +3756,7 @@ describe('AnthropicContentConverter', () => { }); it('carries ttl on both halves of a split system prompt (staticSystemPrefix)', () => { - const { system } = converter.convertGeminiRequestToAnthropic( + const { system } = converter.convertLlmRequestToAnthropic( { model: 'models/test', contents: 'hi', diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index 1f3d749cb4c..5f5cb8b40fd 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -94,7 +94,7 @@ export type CacheRetentionByBlock = Partial< Record<'system' | 'tool' | 'user.last', CacheRetention> >; -export interface ConvertGeminiRequestToAnthropicOptions { +export interface ConvertLlmRequestToAnthropicOptions { /** * On every assistant turn, fill in `signature: ''` on any `thinking` block * that lacks the required `signature` field. Preserves the original @@ -221,7 +221,7 @@ export class AnthropicContentConverter { * Per-request tool ID sanitization state (see {@link resolveToolUseId}). * The converter instance is long-lived across requests (constructed once * per generator), so this state is reset at the top of every - * `convertGeminiRequestToAnthropic` call rather than at construction. + * `convertLlmRequestToAnthropic` call rather than at construction. */ private readonly toolIdMap = new Map(); private readonly usedToolIds = new Set(); @@ -236,9 +236,9 @@ export class AnthropicContentConverter { this.enableCacheControl = enableCacheControl; } - convertGeminiRequestToAnthropic( + convertLlmRequestToAnthropic( request: GenerateContentParameters, - options: ConvertGeminiRequestToAnthropicOptions = {}, + options: ConvertLlmRequestToAnthropicOptions = {}, ): { system?: AnthropicTextBlockParam[] | string; messages: AnthropicMessageParam[]; @@ -354,8 +354,8 @@ export class AnthropicContentConverter { }; } - async convertGeminiToolsToAnthropic( - geminiTools: ToolListUnion, + async convertLlmToolsToAnthropic( + llmTools: ToolListUnion, options: { enableCacheControl?: boolean; useGlobalCacheScope?: boolean; @@ -365,7 +365,7 @@ export class AnthropicContentConverter { ): Promise { const tools: AnthropicToolParam[] = []; - for (const tool of geminiTools) { + for (const tool of llmTools) { let actualTool: Tool; if ('tool' in tool) { @@ -417,7 +417,7 @@ export class AnthropicContentConverter { // ship the standard per-session shape so they don't see a scope // extension they may not recognize. // Per-call overrides mirror the request-shape gates in - // `convertGeminiRequestToAnthropic` so a qwen-oauth-style hot flip of + // `convertLlmRequestToAnthropic` so a qwen-oauth-style hot flip of // `enableCacheControl` (the only field `Config.handleModelChange()` // mutates in place without recreating the generator) doesn't leave // the tool body and the beta header out of sync. `baseUrl` isn't @@ -446,10 +446,10 @@ export class AnthropicContentConverter { return tools; } - convertAnthropicResponseToGemini( + convertAnthropicResponseToLlm( response: Anthropic.Message, ): GenerateContentResponse { - const geminiResponse = new GenerateContentResponse(); + const llmResponse = new GenerateContentResponse(); const parts: Part[] = []; for (const block of response.content || []) { @@ -506,21 +506,21 @@ export class AnthropicContentConverter { safetyRatings: [], }; - const finishReason = this.mapAnthropicFinishReasonToGemini( + const finishReason = this.mapAnthropicFinishReasonToLlm( response.stop_reason, ); if (finishReason) { candidate.finishReason = finishReason; } - geminiResponse.candidates = [candidate]; - geminiResponse.responseId = response.id; - geminiResponse.createTime = Date.now().toString(); - geminiResponse.modelVersion = response.model || undefined; - geminiResponse.promptFeedback = { safetyRatings: [] }; + llmResponse.candidates = [candidate]; + llmResponse.responseId = response.id; + llmResponse.createTime = Date.now().toString(); + llmResponse.modelVersion = response.model || undefined; + llmResponse.promptFeedback = { safetyRatings: [] }; if (response.usage) { - geminiResponse.usageMetadata = buildAnthropicUsageMetadata({ + llmResponse.usageMetadata = buildAnthropicUsageMetadata({ inputTokens: response.usage.input_tokens || 0, cacheReadTokens: response.usage.cache_read_input_tokens || 0, cacheCreationTokens: response.usage.cache_creation_input_tokens || 0, @@ -532,7 +532,7 @@ export class AnthropicContentConverter { }); } - return geminiResponse; + return llmResponse; } private processContents( @@ -697,7 +697,7 @@ export class AnthropicContentConverter { * The same source ID always resolves to the same wire ID within a * request (memoized in `toolIdMap`), so a `tool_use`/`tool_result` pair * that shares a source ID still links up correctly after sanitization. - * State is scoped to a single `convertGeminiRequestToAnthropic` call + * State is scoped to a single `convertLlmRequestToAnthropic` call * (reset via {@link resetToolIdState}), since the converter instance * itself is long-lived across requests. */ @@ -904,7 +904,7 @@ export class AnthropicContentConverter { return {}; } - mapAnthropicFinishReasonToGemini( + mapAnthropicFinishReasonToLlm( reason?: string | null, ): FinishReason | undefined { if (!reason) return undefined; @@ -916,7 +916,7 @@ export class AnthropicContentConverter { content_filter: FinishReason.SAFETY, // Anthropic's refusal stop_reason is a provider safety decision; it // must land in the content-filter family so downstream gates (e.g. - // the quiet post-tool-result acceptance in geminiChat, #9026) keep + // the quiet post-tool-result acceptance in llmChat, #9026) keep // it fatal instead of masking it with an "(empty content)" turn. refusal: FinishReason.SAFETY, }; @@ -1255,7 +1255,7 @@ export class AnthropicContentConverter { * synthetic user turn to satisfy Anthropic's "must end with a user * message" requirement (Opus/Sonnet 4.6+, every 5.x family) when the * conversation would otherwise end on a non-empty assistant message. - * See {@link ConvertGeminiRequestToAnthropicOptions.stripTrailingAssistantPrefill}. + * See {@link ConvertLlmRequestToAnthropicOptions.stripTrailingAssistantPrefill}. */ private stripTrailingAssistantPrefill( messages: AnthropicMessageParam[], @@ -1629,7 +1629,7 @@ function cleanOrphanedToolCalls( * regardless of whether a signature is present. This arises when a * `redacted_thinking` block -- whose opaque `data` doesn't survive the * Gemini-`Part` round trip, see - * {@link AnthropicContentConverter.convertAnthropicResponseToGemini} -- + * {@link AnthropicContentConverter.convertAnthropicResponseToLlm} -- * is replayed back through history construction as an empty-text * `thinking` block. * diff --git a/packages/core/src/core/baseLlmClient.ts b/packages/core/src/core/baseLlmClient.ts index df74cec0b7f..76dab2a3378 100644 --- a/packages/core/src/core/baseLlmClient.ts +++ b/packages/core/src/core/baseLlmClient.ts @@ -82,7 +82,7 @@ export interface GenerateTextOptions { model: string; /** * Task-specific system instructions. Passed through to the underlying - * content generator without the geminiClient main-prompt fallback or + * content generator without the llmClient main-prompt fallback or * user-memory wrapping that `getCustomSystemPrompt` applies. */ systemInstruction?: GenerateContentConfig['systemInstruction']; @@ -365,7 +365,7 @@ export class BaseLlmClient { /** * Free-form text generation primitive used by `runSideQuery` text mode. * - * Distinct from `GeminiClient.generateContent`: this calls the underlying + * Distinct from `LlmClient.generateContent`: this calls the underlying * `ContentGenerator` directly, so the caller's `systemInstruction` is sent * through verbatim — no `getCustomSystemPrompt` wrapping (which would append * user memory) and no main-session-prompt fallback when omitted. Side queries diff --git a/packages/core/src/core/client-goal.test.ts b/packages/core/src/core/client-goal.test.ts index b21783661cd..bc6c2796569 100644 --- a/packages/core/src/core/client-goal.test.ts +++ b/packages/core/src/core/client-goal.test.ts @@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; -import type { GeminiChat } from './geminiChat.js'; +import type { LlmChat } from './llm-chat.js'; import { createGoalRuntime, GoalPersistenceUnavailableError, @@ -49,8 +49,8 @@ vi.mock('../utils/nextSpeakerChecker.js', () => ({ checkNextSpeaker: nextSpeakerMocks.check, })); -import { GeminiClient, SendMessageType } from './client.js'; -import { GeminiEventType, type ServerGeminiStreamEvent } from './turn.js'; +import { LlmClient, SendMessageType } from './client.js'; +import { LlmEventType, type ServerLlmStreamEvent } from './turn.js'; const FORMER_GOAL_CONTINUATION_LIMIT = 50; @@ -87,26 +87,26 @@ async function collectOutcome(stream: AsyncGenerator) { } type GoalStateEvent = Extract< - ServerGeminiStreamEvent, - { type: GeminiEventType.GoalState } + ServerLlmStreamEvent, + { type: LlmEventType.GoalState } >; function goalStateEvents(events: unknown[]): GoalStateEvent[] { return events.filter( (event): event is GoalStateEvent => - (event as { type?: GeminiEventType }).type === GeminiEventType.GoalState, + (event as { type?: LlmEventType }).type === LlmEventType.GoalState, ); } function eventIndex( events: unknown[], - type: GeminiEventType, - predicate: (event: ServerGeminiStreamEvent) => boolean = () => true, + type: LlmEventType, + predicate: (event: ServerLlmStreamEvent) => boolean = () => true, ) { return events.findIndex( (event) => - (event as { type?: GeminiEventType }).type === type && - predicate(event as ServerGeminiStreamEvent), + (event as { type?: LlmEventType }).type === type && + predicate(event as ServerLlmStreamEvent), ); } @@ -236,19 +236,19 @@ function setupGoalClient() { getSnapshots: vi.fn(() => []), })), } as unknown as Config; - const client = new GeminiClient(config); + const client = new LlmClient(config); client['chat'] = { getUserContentPushCount: vi.fn(() => 0), getHistory: vi.fn(() => []), getHistoryLength: vi.fn(() => 0), - } as unknown as GeminiChat; + } as unknown as LlmChat; client['drainPendingAddedMcpToolsReminder'] = vi.fn(); client['drainSkillAndCommandReminders'] = vi.fn(async () => undefined); client['drainAgentReminders'] = vi.fn(async () => undefined); return { client, config, runtime, recorder, order, unsubscribeGoalState }; } -describe('GeminiClient Goal admission', () => { +describe('LlmClient Goal admission', () => { beforeEach(() => { turnMocks.constructors.length = 0; turnMocks.run.mockReset().mockImplementation(emptyStream); @@ -259,7 +259,7 @@ describe('GeminiClient Goal admission', () => { it('exposes Goal as an explicit internal message type', () => { expect(SendMessageType.Goal).toBe('goal'); - expect(GeminiEventType.GoalState).toBe('goal_state'); + expect(LlmEventType.GoalState).toBe('goal_state'); }); it('flushes and queues real user input before finishing an exact Goal permit', async () => { @@ -301,20 +301,19 @@ describe('GeminiClient Goal admission', () => { ]); const initialGoalStateIndex = eventIndex( events, - GeminiEventType.GoalState, + LlmEventType.GoalState, (event) => - event.type === GeminiEventType.GoalState && event.cause === undefined, + event.type === LlmEventType.GoalState && event.cause === undefined, ); const initialActiveGoalIndex = eventIndex( events, - GeminiEventType.ActiveGoal, - (event) => - event.type === GeminiEventType.ActiveGoal && event.value !== null, + LlmEventType.ActiveGoal, + (event) => event.type === LlmEventType.ActiveGoal && event.value !== null, ); expect(initialGoalStateIndex).toBeGreaterThanOrEqual(0); expect(initialActiveGoalIndex).toBeGreaterThan(initialGoalStateIndex); expect(events[initialActiveGoalIndex]).toEqual({ - type: GeminiEventType.ActiveGoal, + type: LlmEventType.ActiveGoal, value: { condition: 'ship', iterations: 0, @@ -450,12 +449,12 @@ describe('GeminiClient Goal admission', () => { 'turn_finished', ]); expect( - eventIndex(events, GeminiEventType.GoalState, (event) => - event.type === GeminiEventType.GoalState + eventIndex(events, LlmEventType.GoalState, (event) => + event.type === LlmEventType.GoalState ? event.cause === 'turn_finished' : false, ), - ).toBeLessThan(eventIndex(events, GeminiEventType.UserPromptSubmitBlocked)); + ).toBeLessThan(eventIndex(events, LlmEventType.UserPromptSubmitBlocked)); }); it('pauses and releases a hidden exact permit when UserPromptSubmit throws', async () => { @@ -622,7 +621,7 @@ describe('GeminiClient Goal admission', () => { turnMocks.run.mockImplementationOnce(() => (async function* () { caller.abort(); - yield { type: GeminiEventType.UserCancelled }; + yield { type: LlmEventType.UserCancelled }; })(), ); @@ -651,12 +650,12 @@ describe('GeminiClient Goal admission', () => { 'turn_finished', ]); expect( - eventIndex(events, GeminiEventType.GoalState, (event) => - event.type === GeminiEventType.GoalState + eventIndex(events, LlmEventType.GoalState, (event) => + event.type === LlmEventType.GoalState ? event.cause === 'turn_finished' : false, ), - ).toBeLessThan(eventIndex(events, GeminiEventType.UserCancelled)); + ).toBeLessThan(eventIndex(events, LlmEventType.UserCancelled)); }); it('pauses and releases the current permit when model setup throws', async () => { @@ -745,17 +744,16 @@ describe('GeminiClient Goal admission', () => { expect(messageBus.request).toHaveBeenCalledTimes(2); const pauseStateIndex = eventIndex( events, - GeminiEventType.GoalState, + LlmEventType.GoalState, (event) => - event.type === GeminiEventType.GoalState && event.cause === 'pause', + event.type === LlmEventType.GoalState && event.cause === 'pause', ); const inactiveProjectionIndex = eventIndex( events, - GeminiEventType.ActiveGoal, - (event) => - event.type === GeminiEventType.ActiveGoal && event.value === null, + LlmEventType.ActiveGoal, + (event) => event.type === LlmEventType.ActiveGoal && event.value === null, ); - const loopIndex = eventIndex(events, GeminiEventType.StopHookLoop); + const loopIndex = eventIndex(events, LlmEventType.StopHookLoop); expect(pauseStateIndex).toBeGreaterThanOrEqual(0); expect(inactiveProjectionIndex).toBeGreaterThan(pauseStateIndex); expect(loopIndex).toBeGreaterThan(inactiveProjectionIndex); @@ -796,27 +794,26 @@ describe('GeminiClient Goal admission', () => { const pauseStateIndex = eventIndex( events, - GeminiEventType.GoalState, + LlmEventType.GoalState, (event) => - event.type === GeminiEventType.GoalState && event.cause === 'pause', + event.type === LlmEventType.GoalState && event.cause === 'pause', ); const inactiveProjectionIndex = eventIndex( events, - GeminiEventType.ActiveGoal, - (event) => - event.type === GeminiEventType.ActiveGoal && event.value === null, + LlmEventType.ActiveGoal, + (event) => event.type === LlmEventType.ActiveGoal && event.value === null, ); const finishStateIndex = eventIndex( events, - GeminiEventType.GoalState, + LlmEventType.GoalState, (event) => - event.type === GeminiEventType.GoalState && + event.type === LlmEventType.GoalState && event.cause === 'turn_finished', ); expect(pauseStateIndex).toBeGreaterThanOrEqual(0); expect(inactiveProjectionIndex).toBeGreaterThan(pauseStateIndex); expect(finishStateIndex).toBeGreaterThan(inactiveProjectionIndex); - expect(eventIndex(events, GeminiEventType.StopHookLoop)).toBe(-1); + expect(eventIndex(events, LlmEventType.StopHookLoop)).toBe(-1); expect(runtime.finishTurn).toHaveBeenCalledOnce(); }); @@ -950,7 +947,7 @@ describe('GeminiClient Goal admission', () => { expect(turnMocks.run).toHaveBeenCalledOnce(); expect(order).toEqual(['pause', 'flush', 'finish']); expect(events).toContainEqual({ - type: GeminiEventType.HookSystemMessage, + type: LlmEventType.HookSystemMessage, value: 'Stop hook blocked continuation 1 consecutive time; overriding and ending the turn.', }); diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 3f938fe661e..2403fef56a7 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -22,7 +22,7 @@ import { mkdtemp, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { Content, GenerateContentResponse, Part } from '@google/genai'; -import { GeminiClient, SendMessageType, type SteerInput } from './client.js'; +import { LlmClient, SendMessageType, type SteerInput } from './client.js'; import { MESSAGE_DISPLAY_DEBOUNCE_MS } from './message-display-buffer.js'; import { getRecentGitStatus } from '../utils/gitUtils.js'; import { @@ -33,7 +33,7 @@ import { } from './contentGenerator.js'; import { BaseLlmClient } from './baseLlmClient.js'; import { buildAgentContentGeneratorConfig } from '../models/content-generator-config.js'; -import { GeminiChat } from './geminiChat.js'; +import { LlmChat } from './llm-chat.js'; import { DEFAULT_TOKEN_LIMIT } from './tokenLimits.js'; import type { Config } from '../config/config.js'; import { ApprovalMode } from '../config/config.js'; @@ -47,9 +47,9 @@ import { UnauthorizedError } from '../utils/errors.js'; import { retryWithBackoff } from '../utils/retry.js'; import { CompressionStatus, - GeminiEventType, + LlmEventType, Turn, - type ServerGeminiStreamEvent, + type ServerLlmStreamEvent, } from './turn.js'; import { LoopType } from '../telemetry/types.js'; import { logMemoryRecallDelivery } from '../telemetry/index.js'; @@ -462,7 +462,7 @@ function getLastTurnRequestText(): string { describe('Gemini Client (client.ts)', () => { let mockContentGenerator: ContentGenerator; let mockConfig: Config; - let client: GeminiClient; + let client: LlmClient; let mockGenerateContentFn: Mock; let mockFileHistoryService: { makeSnapshot: ReturnType; @@ -545,7 +545,7 @@ describe('Gemini Client (client.ts)', () => { batchEmbedContents: vi.fn(), } as unknown as ContentGenerator; - // Because the GeminiClient constructor kicks off an async process (startChat) + // Because the LlmClient constructor kicks off an async process (startChat) // that depends on a fully-formed Config object, we need to mock the // entire implementation of Config for these tests. const mockToolRegistry = { @@ -618,7 +618,7 @@ describe('Gemini Client (client.ts)', () => { getWorkspaceContext: vi.fn().mockReturnValue({ getDirectories: vi.fn().mockReturnValue(['/test/dir']), }), - getGeminiClient: vi.fn(), + getLlmClient: vi.fn(), getModelRouterService: vi.fn().mockReturnValue({ route: vi.fn().mockResolvedValue({ model: 'default-routed-model' }), }), @@ -700,11 +700,11 @@ describe('Gemini Client (client.ts)', () => { }); vi.mocked(mockConfig.getBaseLlmClient).mockReturnValue(realBaseLlmClient); - client = new GeminiClient(mockConfig); + client = new LlmClient(mockConfig); await client.initialize(); - vi.mocked(mockConfig.getGeminiClient).mockReturnValue(client); + vi.mocked(mockConfig.getLlmClient).mockReturnValue(client); - // GeminiClient.sendMessageStream calls this.tryCompressChat (which now + // LlmClient.sendMessageStream calls this.tryCompressChat (which now // delegates to chat.tryCompress) before each turn. Most tests use a // hand-rolled chat mock that doesn't implement tryCompress; default the // wrapper to a NOOP so those tests don't crash. Tests that exercise @@ -726,7 +726,7 @@ describe('Gemini Client (client.ts)', () => { describe('initialize', () => { it('initializes from the selective runtime projection without the full transcript', async () => { const seedResumeTokenCountsSpy = vi.spyOn( - GeminiChat.prototype, + LlmChat.prototype, 'seedResumeTokenCounts', ); const apiHistory = [ @@ -750,7 +750,7 @@ describe('Gemini Client (client.ts)', () => { backgroundNotificationTaskIds: [], } as unknown as ReturnType); - const resumedClient = new GeminiClient(mockConfig); + const resumedClient = new LlmClient(mockConfig); await resumedClient.initialize(); expect(resumedClient.getHistory().at(-1)).toEqual(apiHistory[0]); @@ -780,7 +780,7 @@ describe('Gemini Client (client.ts)', () => { 123_456, ); - const resumedClient = new GeminiClient(mockConfig); + const resumedClient = new LlmClient(mockConfig); await resumedClient.initialize(); expect(resumedClient.getChat().getLastPromptTokenCount()).toBe(123_456); @@ -788,7 +788,7 @@ describe('Gemini Client (client.ts)', () => { it('seeds resumed chat with previous response output token count', async () => { const seedResumeTokenCountsSpy = vi.spyOn( - GeminiChat.prototype, + LlmChat.prototype, 'seedResumeTokenCounts', ); vi.mocked(mockConfig.getResumedSessionData).mockReturnValue({ @@ -820,7 +820,7 @@ describe('Gemini Client (client.ts)', () => { lastCompletedUuid: null, }); - const resumedClient = new GeminiClient(mockConfig); + const resumedClient = new LlmClient(mockConfig); await resumedClient.initialize(); expect(resumedClient.getChat().getLastPromptTokenCount()).toBe(200); @@ -829,7 +829,7 @@ describe('Gemini Client (client.ts)', () => { it('restores estimated provenance from a compression checkpoint', async () => { const seedResumeTokenCountsSpy = vi.spyOn( - GeminiChat.prototype, + LlmChat.prototype, 'seedResumeTokenCounts', ); vi.mocked(mockConfig.getResumedSessionData).mockReturnValue({ @@ -864,7 +864,7 @@ describe('Gemini Client (client.ts)', () => { lastCompletedUuid: null, }); - const resumedClient = new GeminiClient(mockConfig); + const resumedClient = new LlmClient(mockConfig); await resumedClient.initialize(); expect(seedResumeTokenCountsSpy).toHaveBeenCalledWith(200, 0, true); @@ -929,7 +929,7 @@ describe('Gemini Client (client.ts)', () => { lastCompletedUuid: null, } as unknown as ReturnType); - const resumedClient = new GeminiClient(mockConfig); + const resumedClient = new LlmClient(mockConfig); await resumedClient.initialize(); expect(resumedClient['recentCompletedToolNames']).toEqual(['read_file']); @@ -951,7 +951,7 @@ describe('Gemini Client (client.ts)', () => { hookSystem as unknown as ReturnType, ); - const freshClient = new GeminiClient(mockConfig); + const freshClient = new LlmClient(mockConfig); await freshClient.initialize(); expect(hookSystem.fireSessionStartEvent).toHaveBeenCalledWith( @@ -977,7 +977,7 @@ describe('Gemini Client (client.ts)', () => { hookSystem as unknown as ReturnType, ); - const freshClient = new GeminiClient(mockConfig); + const freshClient = new LlmClient(mockConfig); await freshClient.initialize(); const firstChat = freshClient.getChat(); await freshClient.initialize(SessionStartSource.Resume); @@ -1004,7 +1004,7 @@ describe('Gemini Client (client.ts)', () => { .mockReturnValueOnce('session-a') .mockReturnValueOnce('session-b'); - const freshClient = new GeminiClient(mockConfig); + const freshClient = new LlmClient(mockConfig); await freshClient.initialize(); const firstChat = freshClient.getChat(); await freshClient.initialize(SessionStartSource.Resume); @@ -1104,7 +1104,7 @@ describe('Gemini Client (client.ts)', () => { it('enables manual plan-exit notices on every main chat', async () => { const enableSpy = vi.spyOn( - GeminiChat.prototype, + LlmChat.prototype, 'enableManualPlanExitNotices', ); @@ -1709,7 +1709,7 @@ describe('Gemini Client (client.ts)', () => { }); it('re-applies SessionStart additionalContext after refreshing the system instruction', async () => { - // startChat() calls getCoreSystemPrompt for the initial GeminiChat + // startChat() calls getCoreSystemPrompt for the initial LlmChat // construction. The second call is refreshSystemInstruction under test. vi.mocked(getCoreSystemPrompt) .mockReturnValueOnce('Base instruction') @@ -1795,11 +1795,11 @@ describe('Gemini Client (client.ts)', () => { { role: 'user', parts: [{ text: 'hello' }] }, { role: 'model', parts: [{ text: 'hi' }] }, ]; - const mockChat: Partial = { + const mockChat: Partial = { getHistory: vi.fn().mockReturnValue(currentHistory), setHistory: vi.fn(), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; vi.mocked(getInitialChatHistory).mockResolvedValueOnce([[], []]); await client.refreshStartupContextReminder(); @@ -1833,11 +1833,11 @@ describe('Gemini Client (client.ts)', () => { { text: '\nfresh prelude\n' }, ], }; - const mockChat: Partial = { + const mockChat: Partial = { getHistory: vi.fn().mockReturnValue(currentHistory), setHistory: vi.fn(), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; vi.mocked(getInitialChatHistory).mockResolvedValueOnce([ [newPrelude], [], @@ -2072,7 +2072,7 @@ describe('Gemini Client (client.ts)', () => { ): Promise { mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'response' }; + yield { type: LlmEventType.Content, value: 'response' }; })(), ); @@ -2107,7 +2107,7 @@ describe('Gemini Client (client.ts)', () => { mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'response' }; + yield { type: LlmEventType.Content, value: 'response' }; })(), ); const stream = client.sendMessageStream( @@ -2178,7 +2178,7 @@ describe('Gemini Client (client.ts)', () => { it('continues the carried Todo work chain for related notifications', async () => { mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'response' }; + yield { type: LlmEventType.Content, value: 'response' }; })(), ); @@ -2209,14 +2209,14 @@ describe('Gemini Client (client.ts)', () => { .mockReturnValueOnce( (async function* () { yield { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'call-1', name: 'read_file', args: {} }, }; })(), ) .mockReturnValueOnce( (async function* () { - yield { type: GeminiEventType.Content, value: 'done' }; + yield { type: LlmEventType.Content, value: 'done' }; })(), ); @@ -2743,7 +2743,7 @@ describe('Gemini Client (client.ts)', () => { it('should call chat.addHistory with the provided content', async () => { const mockChat = { addHistory: vi.fn(), - } as unknown as GeminiChat; + } as unknown as LlmChat; client['chat'] = mockChat; const newContent = { @@ -2973,7 +2973,7 @@ describe('Gemini Client (client.ts)', () => { const cacheClear = mockFileReadCacheClear(); client['chat'] = { setHistory: vi.fn(), - } as unknown as GeminiChat; + } as unknown as LlmChat; client.setHistory([{ role: 'user', parts: [{ text: 'replaced' }] }]); @@ -2981,19 +2981,19 @@ describe('Gemini Client (client.ts)', () => { }); /** - * Test helper: mock a GeminiChat whose history length goes from + * Test helper: mock a LlmChat whose history length goes from * `before` to `after` across truncateHistory(). The first * getHistoryLength() call (pre-truncate) returns `before`; the * second (post-truncate) returns `after`. */ - function mockChatWithLengths(before: number, after: number): GeminiChat { + function mockChatWithLengths(before: number, after: number): LlmChat { return { getHistoryLength: vi .fn() .mockReturnValueOnce(before) .mockReturnValueOnce(after), truncateHistory: vi.fn(), - } as unknown as GeminiChat; + } as unknown as LlmChat; } it('truncateHistory clears the cache when entries are actually removed', () => { @@ -3040,7 +3040,7 @@ describe('Gemini Client (client.ts)', () => { getHistoryLength, getHistory, truncateHistory: vi.fn(), - } as unknown as GeminiChat; + } as unknown as LlmChat; client.truncateHistory(3); @@ -3055,7 +3055,7 @@ describe('Gemini Client (client.ts)', () => { client['chat'] = { getHistoryLength: vi.fn().mockReturnValueOnce(3).mockReturnValueOnce(1), stripOrphanedUserEntriesFromHistory: strip, - } as unknown as GeminiChat; + } as unknown as LlmChat; client['forceFullIdeContext'] = false; client.stripOrphanedUserEntriesFromHistory(); @@ -3070,7 +3070,7 @@ describe('Gemini Client (client.ts)', () => { client['chat'] = { getHistoryLength: vi.fn().mockReturnValue(2), stripOrphanedUserEntriesFromHistory: strip2, - } as unknown as GeminiChat; + } as unknown as LlmChat; client['forceFullIdeContext'] = false; client.stripOrphanedUserEntriesFromHistory(); @@ -3098,10 +3098,10 @@ describe('Gemini Client (client.ts)', () => { getHistoryLength, stripOrphanedUserEntriesFromHistory, repairOrphanedToolUseTurns: vi.fn().mockReturnValue({ injected: [] }), - } as unknown as GeminiChat; + } as unknown as LlmChat; mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'response' }; + yield { type: LlmEventType.Content, value: 'response' }; })(), ); @@ -3136,7 +3136,7 @@ describe('Gemini Client (client.ts)', () => { .fn() .mockReturnValue([retryEntry]), repairOrphanedToolUseTurns: vi.fn().mockReturnValue({ injected: [] }), - } as unknown as GeminiChat; + } as unknown as LlmChat; vi.mocked(mockConfig.getSessionTokenLimit).mockReturnValue(100); vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue( 101, @@ -3151,7 +3151,7 @@ describe('Gemini Client (client.ts)', () => { ), ); - expect(events[0]?.type).toBe(GeminiEventType.SessionTokenLimitExceeded); + expect(events[0]?.type).toBe(LlmEventType.SessionTokenLimitExceeded); expect(mockTurnRunFn).not.toHaveBeenCalled(); expect(addHistory).toHaveBeenCalledWith(retryEntry); }); @@ -3175,7 +3175,7 @@ describe('Gemini Client (client.ts)', () => { route = 'route-b'; mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'response' }; + yield { type: LlmEventType.Content, value: 'response' }; })(), ); @@ -3189,7 +3189,7 @@ describe('Gemini Client (client.ts)', () => { expect(events).not.toContainEqual( expect.objectContaining({ - type: GeminiEventType.SessionTokenLimitExceeded, + type: LlmEventType.SessionTokenLimitExceeded, }), ); expect(telemetryCount).toBe(0); @@ -3218,7 +3218,7 @@ describe('Gemini Client (client.ts)', () => { ); expect(events).toContainEqual({ - type: GeminiEventType.SessionTokenLimitExceeded, + type: LlmEventType.SessionTokenLimitExceeded, value: expect.objectContaining({ currentTokens: 101, limit: 100 }), }); expect(mockTurnRunFn).not.toHaveBeenCalled(); @@ -3226,7 +3226,7 @@ describe('Gemini Client (client.ts)', () => { it('applies the session limit to a resolved full-turn route selector', async () => { // The vision-bridge full-turn selector `${id}\0${baseUrl}\0` arrives as - // modelOverride. GeminiChat.sendMessageStream resolves it and stamps + // modelOverride. LlmChat.sendMessageStream resolves it and stamps // counts under the RESOLVED route's identity, so the gate must resolve // the selector before keying — the raw selector key (always containing // a NUL) can never match a stamped count (#9454). @@ -3263,7 +3263,7 @@ describe('Gemini Client (client.ts)', () => { { failClosed: true }, ); expect(events).toContainEqual({ - type: GeminiEventType.SessionTokenLimitExceeded, + type: LlmEventType.SessionTokenLimitExceeded, value: expect.objectContaining({ currentTokens: 101, limit: 100 }), }); expect(mockTurnRunFn).not.toHaveBeenCalled(); @@ -3283,7 +3283,7 @@ describe('Gemini Client (client.ts)', () => { client.getChat().setLastPromptTokenCount(101); mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'response' }; + yield { type: LlmEventType.Content, value: 'response' }; })(), ); @@ -3299,7 +3299,7 @@ describe('Gemini Client (client.ts)', () => { ); expect(foreignEvents).not.toContainEqual( expect.objectContaining({ - type: GeminiEventType.SessionTokenLimitExceeded, + type: LlmEventType.SessionTokenLimitExceeded, }), ); @@ -3314,7 +3314,7 @@ describe('Gemini Client (client.ts)', () => { ), ); expect(events).toContainEqual({ - type: GeminiEventType.SessionTokenLimitExceeded, + type: LlmEventType.SessionTokenLimitExceeded, value: expect.objectContaining({ currentTokens: 101, limit: 100 }), }); expect(mockTurnRunFn).toHaveBeenCalledTimes(1); @@ -3361,12 +3361,12 @@ describe('Gemini Client (client.ts)', () => { } describe('thinking block idle cleanup and latch', () => { - let mockChat: Partial; + let mockChat: Partial; beforeEach(() => { const mockStream = (async function* () { yield { - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'response', }; })(); @@ -3382,7 +3382,7 @@ describe('Gemini Client (client.ts)', () => { compressionStatus: CompressionStatus.NOOP, }), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; }); it('should update lastApiCompletionTimestamp after API call', async () => { @@ -3489,7 +3489,7 @@ describe('Gemini Client (client.ts)', () => { mcTmpDir = await mkdtemp(join(tmpdir(), 'qwen-mc-cache-')); mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'response' }; + yield { type: LlmEventType.Content, value: 'response' }; })(), ); }); @@ -3512,7 +3512,7 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(history), setHistory, - } as unknown as GeminiChat; + } as unknown as LlmChat; client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000; const stream = client.sendMessageStream( @@ -3544,10 +3544,10 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(history), setHistory: vi.fn(), - } as unknown as GeminiChat; + } as unknown as LlmChat; client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000; - const events: ServerGeminiStreamEvent[] = []; + const events: ServerLlmStreamEvent[] = []; const stream = client.sendMessageStream( [{ text: 'hi' }], new AbortController().signal, @@ -3559,7 +3559,7 @@ describe('Gemini Client (client.ts)', () => { } expect(events).toEqual([ - { type: GeminiEventType.Content, value: 'response' }, + { type: LlmEventType.Content, value: 'response' }, ]); }); @@ -3572,7 +3572,7 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(history), setHistory, - } as unknown as GeminiChat; + } as unknown as LlmChat; client['lastApiCompletionTimestamp'] = Date.now(); client['lastHookMicrocompactionTimestamp'] = Date.now() - 90 * 60_000; @@ -3608,13 +3608,13 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(history), setHistory: vi.fn(), - } as unknown as GeminiChat; + } as unknown as LlmChat; client['lastApiCompletionTimestamp'] = Date.now(); const checkpoint = Date.now() - 90 * 60_000; client['lastHookMicrocompactionTimestamp'] = checkpoint; mockClientDebugLogger.error.mockClear(); - const events: ServerGeminiStreamEvent[] = []; + const events: ServerLlmStreamEvent[] = []; const stream = client.sendMessageStream( [{ text: 'continue goal' }], new AbortController().signal, @@ -3626,7 +3626,7 @@ describe('Gemini Client (client.ts)', () => { } expect(events).toEqual([ - { type: GeminiEventType.Content, value: 'response' }, + { type: LlmEventType.Content, value: 'response' }, ]); expect(mockClientDebugLogger.error).toHaveBeenCalledWith( expect.stringContaining( @@ -3645,7 +3645,7 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(history), setHistory, - } as unknown as GeminiChat; + } as unknown as LlmChat; client['lastApiCompletionTimestamp'] = Date.now(); client['lastHookMicrocompactionTimestamp'] = Date.now() - 90 * 60_000; @@ -3694,7 +3694,7 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(history), setHistory, - } as unknown as GeminiChat; + } as unknown as LlmChat; client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000; client['lastHookMicrocompactionTimestamp'] = null; @@ -3725,7 +3725,7 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(history), setHistory, - } as unknown as GeminiChat; + } as unknown as LlmChat; client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000; client['lastHookMicrocompactionTimestamp'] = Date.now(); @@ -3753,7 +3753,7 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(history), setHistory, - } as unknown as GeminiChat; + } as unknown as LlmChat; client['lastApiCompletionTimestamp'] = null; client['lastHookMicrocompactionTimestamp'] = null; const before = Date.now(); @@ -3812,7 +3812,7 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(idless), setHistory: vi.fn(), - } as unknown as GeminiChat; + } as unknown as LlmChat; client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000; const stream = client.sendMessageStream( @@ -3871,7 +3871,7 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(history), setHistory: vi.fn(), - } as unknown as GeminiChat; + } as unknown as LlmChat; client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000; const stream = client.sendMessageStream( @@ -3942,7 +3942,7 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(history), setHistory: vi.fn(), - } as unknown as GeminiChat; + } as unknown as LlmChat; client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000; const stream = client.sendMessageStream( @@ -3974,7 +3974,7 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(history), setHistory: vi.fn(), - } as unknown as GeminiChat; + } as unknown as LlmChat; client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000; const stream = client.sendMessageStream( @@ -4000,7 +4000,7 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(history), setHistory: vi.fn(), - } as unknown as GeminiChat; + } as unknown as LlmChat; // Recent activity — microcompaction must not fire. client['lastApiCompletionTimestamp'] = Date.now() - 30 * 1000; @@ -4026,7 +4026,7 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(history), setHistory, - } as unknown as GeminiChat; + } as unknown as LlmChat; client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000; const stream = client.sendMessageStream( @@ -4052,7 +4052,7 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(history), setHistory, - } as unknown as GeminiChat; + } as unknown as LlmChat; client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000; const stream = client.sendMessageStream( @@ -4079,7 +4079,7 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(history), setHistory, - } as unknown as GeminiChat; + } as unknown as LlmChat; vi.mocked(mockConfig.getClearContextOnIdle).mockReturnValue({ toolResultsThresholdMinutes: 60, toolResultsNumToKeep: 1, @@ -4136,7 +4136,7 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(history), setHistory, - } as unknown as GeminiChat; + } as unknown as LlmChat; vi.mocked(mockConfig.getClearContextOnIdle).mockReturnValue({ toolResultsThresholdMinutes: 60, toolResultsNumToKeep: 1, @@ -4186,7 +4186,7 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(history), setHistory, - } as unknown as GeminiChat; + } as unknown as LlmChat; vi.mocked(mockConfig.getClearContextOnIdle).mockReturnValue({ toolResultsThresholdMinutes: 60, toolResultsNumToKeep: 2, @@ -4232,7 +4232,7 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(history), setHistory, - } as unknown as GeminiChat; + } as unknown as LlmChat; client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000; const stream = client.sendMessageStream( @@ -4257,7 +4257,7 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(history), setHistory, - } as unknown as GeminiChat; + } as unknown as LlmChat; client['lastApiCompletionTimestamp'] = Date.now(); const checkpoint = Date.now() - 90 * 60_000; client['lastHookMicrocompactionTimestamp'] = checkpoint; @@ -4289,7 +4289,7 @@ describe('Gemini Client (client.ts)', () => { stripOrphanedUserEntriesFromHistory: vi.fn(), getHistoryFunctionResponseIds: vi.fn().mockReturnValue(new Set()), setHistory, - } as unknown as GeminiChat; + } as unknown as LlmChat; client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000; const stream = client.sendMessageStream( @@ -4315,7 +4315,7 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(history), setHistory, - } as unknown as GeminiChat; + } as unknown as LlmChat; client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000; vi.mocked(microcompactHistory).mockImplementationOnce(() => { @@ -4363,7 +4363,7 @@ describe('Gemini Client (client.ts)', () => { }); client['chat'] = { compressFast, - } as unknown as GeminiChat; + } as unknown as LlmChat; client['forceFullIdeContext'] = false; const result = await client.tryCompressChatFast(); @@ -4396,7 +4396,7 @@ describe('Gemini Client (client.ts)', () => { }); client['chat'] = { compressFast, - } as unknown as GeminiChat; + } as unknown as LlmChat; client['forceFullIdeContext'] = false; const result = await client.tryCompressChatFast(); @@ -4433,7 +4433,7 @@ describe('Gemini Client (client.ts)', () => { await writeFile(evictedPath, 'test content'); client['chat'] = { compressFast, - } as unknown as GeminiChat; + } as unknown as LlmChat; client['forceFullIdeContext'] = false; const result = await client.tryCompressChatFast(); @@ -4469,7 +4469,7 @@ describe('Gemini Client (client.ts)', () => { await writeFile(join(mcTmpDir, 'test-file.ts'), 'test content'); client['chat'] = { compressFast, - } as unknown as GeminiChat; + } as unknown as LlmChat; client['forceFullIdeContext'] = false; const result = await client.tryCompressChatFast(); @@ -4481,9 +4481,9 @@ describe('Gemini Client (client.ts)', () => { }); }); - // tryCompressChat is now a thin wrapper around GeminiChat.tryCompress. + // tryCompressChat is now a thin wrapper around LlmChat.tryCompress. // The compression logic itself is exercised in chatCompressionService.test.ts - // (token math, threshold checks, hook firing) and geminiChat.test.ts (history + // (token math, threshold checks, hook firing) and llm-chat.test.ts (history // mutation, recording, consecutiveFailures circuit breaker). The tests below cover // only what the wrapper itself adds: argument forwarding and the IDE-context // flag flip. @@ -4503,7 +4503,7 @@ describe('Gemini Client (client.ts)', () => { client['chat'] = { tryCompress, getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; const signal = new AbortController().signal; await client.tryCompressChat('p1', true, signal); @@ -4523,7 +4523,7 @@ describe('Gemini Client (client.ts)', () => { client['chat'] = { tryCompress, getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; await client.tryCompressChat('p1', true, undefined, 'focus on auth bug'); @@ -4541,7 +4541,7 @@ describe('Gemini Client (client.ts)', () => { }), isLastPromptTokenCountEstimated: vi.fn().mockReturnValue(false), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; client['forceFullIdeContext'] = false; await client.tryCompressChat('p2'); @@ -4690,12 +4690,12 @@ describe('Gemini Client (client.ts)', () => { getHistory: vi.fn().mockReturnValue([]), setHistory: vi.fn(), applySessionStartContext: vi.fn(), - } as unknown as GeminiChat; + } as unknown as LlmChat; mockTurnRunFn.mockReturnValue( (async function* () { yield { - type: GeminiEventType.ChatCompressed, + type: LlmEventType.ChatCompressed, value: { originalTokenCount: 1000, newTokenCount: 200, @@ -4742,12 +4742,12 @@ describe('Gemini Client (client.ts)', () => { getHistory: vi.fn().mockReturnValue([]), setHistory: vi.fn(), applySessionStartContext: vi.fn(), - } as unknown as GeminiChat; + } as unknown as LlmChat; mockTurnRunFn.mockReturnValue( (async function* () { yield { - type: GeminiEventType.ChatCompressed, + type: LlmEventType.ChatCompressed, value: { originalTokenCount: 1000, newTokenCount: 200, @@ -4755,13 +4755,13 @@ describe('Gemini Client (client.ts)', () => { }, }; yield { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: undefined, }; })(), ); - const seenEvents: GeminiEventType[] = []; + const seenEvents: LlmEventType[] = []; const stream = client.sendMessageStream( [{ text: 'hi' }], new AbortController().signal, @@ -4773,8 +4773,8 @@ describe('Gemini Client (client.ts)', () => { } expect(seenEvents).toEqual([ - GeminiEventType.ChatCompressed, - GeminiEventType.Finished, + LlmEventType.ChatCompressed, + LlmEventType.Finished, ]); expect(hookSystem.fireSessionStartEvent).toHaveBeenCalledWith( SessionStartSource.Compact, @@ -4801,12 +4801,12 @@ describe('Gemini Client (client.ts)', () => { getHistory: vi.fn().mockReturnValue([]), setHistory: vi.fn(), applySessionStartContext: vi.fn(), - } as unknown as GeminiChat; + } as unknown as LlmChat; mockTurnRunFn.mockReturnValue( (async function* () { yield { - type: GeminiEventType.ChatCompressed, + type: LlmEventType.ChatCompressed, value: { originalTokenCount: 1000, newTokenCount: 200, @@ -4842,12 +4842,12 @@ describe('Gemini Client (client.ts)', () => { getHistory: vi.fn().mockReturnValue([]), setHistory: vi.fn(), applySessionStartContext: vi.fn(), - } as unknown as GeminiChat; + } as unknown as LlmChat; mockTurnRunFn.mockReturnValue( (async function* () { yield { - type: GeminiEventType.ChatCompressed, + type: LlmEventType.ChatCompressed, value: { originalTokenCount: 1000, newTokenCount: 200, @@ -4893,12 +4893,12 @@ describe('Gemini Client (client.ts)', () => { getHistory: vi.fn().mockReturnValue([]), setHistory: vi.fn(), applySessionStartContext: vi.fn(), - } as unknown as GeminiChat; + } as unknown as LlmChat; mockTurnRunFn.mockReturnValue( (async function* () { yield { - type: GeminiEventType.ChatCompressed, + type: LlmEventType.ChatCompressed, value: { originalTokenCount: 1000, newTokenCount: 200, @@ -4906,13 +4906,13 @@ describe('Gemini Client (client.ts)', () => { }, }; yield { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: undefined, }; })(), ); - const seenEvents: GeminiEventType[] = []; + const seenEvents: LlmEventType[] = []; const stream = client.sendMessageStream( [{ text: 'hi' }], new AbortController().signal, @@ -4924,8 +4924,8 @@ describe('Gemini Client (client.ts)', () => { } expect(seenEvents).toEqual([ - GeminiEventType.ChatCompressed, - GeminiEventType.Finished, + LlmEventType.ChatCompressed, + LlmEventType.Finished, ]); expect(debugLogger.warn).toHaveBeenCalledWith( 'SessionStart hook failed: Error: compact hook failed', @@ -4941,7 +4941,7 @@ describe('Gemini Client (client.ts)', () => { compressionStatus: CompressionStatus.NOOP, }), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; client['forceFullIdeContext'] = false; await client.tryCompressChat('p3'); @@ -4962,7 +4962,7 @@ describe('Gemini Client (client.ts)', () => { mockTurnRunFn.mockReturnValue( (async function* () { yield { - type: GeminiEventType.ChatCompressed, + type: LlmEventType.ChatCompressed, value: { originalTokenCount: 1000, newTokenCount: 200, @@ -4975,7 +4975,7 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), setHistory: vi.fn(), - } as unknown as GeminiChat; + } as unknown as LlmChat; client['forceFullIdeContext'] = false; const stream = client.sendMessageStream( @@ -5009,7 +5009,7 @@ describe('Gemini Client (client.ts)', () => { mockTurnRunFn.mockReturnValue( (async function* () { yield { - type: GeminiEventType.ChatCompressed, + type: LlmEventType.ChatCompressed, value: { originalTokenCount: 1000, newTokenCount: 200, @@ -5022,7 +5022,7 @@ describe('Gemini Client (client.ts)', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue(compactedHistory), setHistory, - } as unknown as GeminiChat; + } as unknown as LlmChat; const stream = client.sendMessageStream( [{ text: 'hi' }], @@ -5070,7 +5070,7 @@ describe('Gemini Client (client.ts)', () => { ]); mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'response' }; + yield { type: LlmEventType.Content, value: 'response' }; })(), ); @@ -5111,7 +5111,7 @@ describe('Gemini Client (client.ts)', () => { it('does not re-run session writer admission for a mid-turn hook continuation', async () => { mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'continued' }; + yield { type: LlmEventType.Content, value: 'continued' }; })(), ); @@ -5170,7 +5170,7 @@ describe('Gemini Client (client.ts)', () => { const mockChat = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; client['chat'] = mockChat; const initialRequest: Part[] = [{ text: 'Hi' }]; @@ -5225,11 +5225,11 @@ Other open files: })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const initialRequest = [{ text: 'Hi' }]; @@ -5280,11 +5280,11 @@ Other open files: })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const initialRequest = [{ text: 'Hi' }]; @@ -5345,7 +5345,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; const stream = client.sendMessageStream( [{ text: 'Hi' }], @@ -5423,7 +5423,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; const done = fromAsync( client.sendMessageStream( @@ -5473,7 +5473,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; const done = fromAsync( client.sendMessageStream( @@ -5548,7 +5548,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; const done = fromAsync( client.sendMessageStream( @@ -5606,7 +5606,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; const userDone = fromAsync( client.sendMessageStream( @@ -5683,7 +5683,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; const userDone = fromAsync( client.sendMessageStream( @@ -5759,7 +5759,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; const userDone = fromAsync( client.sendMessageStream( @@ -5847,7 +5847,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; const done = fromAsync( client.sendMessageStream( @@ -5941,7 +5941,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; const done = fromAsync( client.sendMessageStream( @@ -5990,7 +5990,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; const first = fromAsync( client.sendMessageStream( @@ -6053,11 +6053,11 @@ hello })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; client.recordCompletedToolCall('mcp__ata__article-list-query'); const stream = client.sendMessageStream( @@ -6117,11 +6117,11 @@ hello })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const first = client.sendMessageStream( [{ text: 'Please answer tersely' }], @@ -6167,11 +6167,11 @@ hello })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const done = fromAsync( client.sendMessageStream( @@ -6229,7 +6229,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; const done = fromAsync( client.sendMessageStream( @@ -6268,11 +6268,11 @@ hello })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const stream = client.sendMessageStream( [{ text: 'Quick question' }], @@ -6317,7 +6317,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; const stream = client.sendMessageStream( [{ text: 'Quick question' }], @@ -6356,7 +6356,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; const stream = client.sendMessageStream( [{ text: 'Quick question' }], @@ -6433,11 +6433,11 @@ hello })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; // Turn 1: UserQuery — recall still pending, no injection const userStream = client.sendMessageStream( @@ -6530,7 +6530,7 @@ hello mockTurnRunFn.mockReturnValueOnce( (async function* () { yield { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'call-1', name: 'read_file', @@ -6561,7 +6561,7 @@ hello mockTurnRunFn.mockReturnValueOnce( (async function* () { yield { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: 'call-2', name: 'write_file', @@ -6595,7 +6595,7 @@ hello mockTurnRunFn.mockReturnValueOnce( (async function* () { - yield { type: GeminiEventType.Content, value: 'done' }; + yield { type: LlmEventType.Content, value: 'done' }; })(), ); @@ -6624,7 +6624,7 @@ hello it('starts Retry as a fresh agent invocation', async () => { mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'retried' }; + yield { type: LlmEventType.Content, value: 'retried' }; })(), ); @@ -6761,9 +6761,9 @@ hello mockInteractionTelemetry.getActiveInteractionSpan.mockReturnValue(owner); mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'final answer' }; + yield { type: LlmEventType.Content, value: 'final answer' }; yield { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: 'STOP' }, }; })(), @@ -6798,12 +6798,12 @@ hello mockInteractionTelemetry.getActiveInteractionSpan.mockReturnValue(owner); mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'stale answer' }; + yield { type: LlmEventType.Content, value: 'stale answer' }; mockInteractionTelemetry.getActiveInteractionSpan.mockReturnValue( replacement, ); yield { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: 'STOP' }, }; })(), @@ -6829,13 +6829,13 @@ hello it('resets failed provider attempts while preserving continuation retries', async () => { mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'discarded' }; - yield { type: GeminiEventType.Retry, isContinuation: false }; - yield { type: GeminiEventType.Content, value: 'kept ' }; - yield { type: GeminiEventType.Retry, isContinuation: true }; - yield { type: GeminiEventType.Content, value: 'continuation' }; + yield { type: LlmEventType.Content, value: 'discarded' }; + yield { type: LlmEventType.Retry, isContinuation: false }; + yield { type: LlmEventType.Content, value: 'kept ' }; + yield { type: LlmEventType.Retry, isContinuation: true }; + yield { type: LlmEventType.Content, value: 'continuation' }; yield { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: 'STOP' }, }; })(), @@ -6874,7 +6874,7 @@ hello vi.mocked(mockConfig.getJsonSchema).mockReturnValue({ type: 'object' }); mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'goal progress' }; + yield { type: LlmEventType.Content, value: 'goal progress' }; })(), ); @@ -6912,7 +6912,7 @@ hello mockInteractionTelemetry.getActiveInteractionSpan.mockReturnValue(owner); mockTurnRunFn.mockImplementation(() => (async function* () { - yield { type: GeminiEventType.Content, value: 'response' }; + yield { type: LlmEventType.Content, value: 'response' }; })(), ); const getSteerInput = vi @@ -6953,7 +6953,7 @@ hello }); mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'plain text' }; + yield { type: LlmEventType.Content, value: 'plain text' }; })(), ); @@ -6988,7 +6988,7 @@ hello }); mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'drain complete' }; + yield { type: LlmEventType.Content, value: 'drain complete' }; })(), ); @@ -7034,7 +7034,7 @@ hello .mockReturnValueOnce( (async function* () { yield { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: `call-${messageType}`, name: 'read_file', @@ -7047,7 +7047,7 @@ hello ) .mockReturnValueOnce( (async function* () { - yield { type: GeminiEventType.Content, value: 'plain text' }; + yield { type: LlmEventType.Content, value: 'plain text' }; })(), ); @@ -7104,7 +7104,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; const stream = client.sendMessageStream( [{ text: 'no tool calls here' }], @@ -7139,11 +7139,11 @@ hello return new Promise(() => {}); }); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; mockTurnRunFn.mockReturnValue( (async function* () { yield { type: 'content', value: 'Hello' }; @@ -7288,7 +7288,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; const done = fromAsync( client.sendMessageStream( @@ -7337,7 +7337,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; const done = fromAsync( client.sendMessageStream( @@ -7367,11 +7367,11 @@ hello return new Promise(() => {}); }); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; mockTurnRunFn.mockReturnValue( (async function* () { yield { type: 'content', value: 'Hello' }; @@ -7452,12 +7452,12 @@ hello return new Promise(() => {}); }); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), getHistoryLength: vi.fn().mockReturnValue(0), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; mockTurnRunFn.mockReturnValue( (async function* () { yield { type: 'content', value: 'Hello' }; @@ -7497,7 +7497,7 @@ hello addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), getHistoryLength: vi.fn().mockReturnValue(0), - } as unknown as GeminiChat; + } as unknown as LlmChat; mockTurnRunFn.mockReturnValue( (async function* () { yield { type: 'content', value: 'Hello' }; @@ -7546,7 +7546,7 @@ hello addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), getHistoryLength: vi.fn().mockReturnValue(0), - } as unknown as GeminiChat; + } as unknown as LlmChat; mockTurnRunFn.mockReturnValue( (async function* () { yield { type: 'content', value: 'Hello' }; @@ -7597,7 +7597,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; mockTurnRunFn.mockReturnValue( (async function* () { yield { type: 'content', value: 'Hello' }; @@ -7635,7 +7635,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; // ToolCallRequest sets hasToolCalls, then Retry resets it → end-of-turn // sees no tool calls and discards the prefetch. @@ -7686,7 +7686,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; // ToolCallRequest → Retry (resets) → ToolCallRequest (re-sets) → // end-of-turn sees hasToolCalls=true and preserves the prefetch. @@ -7742,7 +7742,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; // ToolCallRequest sets hasToolCalls, then ModelFallback resets it → // end-of-turn sees no tool calls and discards the prefetch. @@ -7798,7 +7798,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; // ToolCallRequest → ModelFallback (resets) → ToolCallRequest (re-sets) → // end-of-turn sees hasToolCalls=true and preserves the prefetch. @@ -7875,7 +7875,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; // Turn 1: prefetch fires, tool call preserves it past end-of-turn. mockTurnRunFn.mockReturnValue( @@ -7986,12 +7986,12 @@ hello return new Promise(() => {}); }); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), getHistoryLength: vi.fn().mockReturnValue(0), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; // Force LoopDetector to trip on the first event. const loopDetector = client['loopDetector']; @@ -8015,7 +8015,7 @@ hello events.push(event); } - expect(events.some((e) => e.type === GeminiEventType.LoopDetected)).toBe( + expect(events.some((e) => e.type === LlmEventType.LoopDetected)).toBe( true, ); expect(abortHandlerInvoked).toBe(true); @@ -8031,12 +8031,12 @@ hello return new Promise(() => {}); }); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), getHistoryLength: vi.fn().mockReturnValue(0), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; // The always-on cap trips on the first event — it runs before (and // independently of) the gated detectors. @@ -8083,7 +8083,7 @@ hello expect(alwaysOnSpy).toHaveBeenCalled(); expect(heuristicSpy).not.toHaveBeenCalled(); const loopEvent = events.find( - (e) => e.type === GeminiEventType.LoopDetected, + (e) => e.type === LlmEventType.LoopDetected, ); expect(loopEvent?.value?.loopType).toBe(LoopType.TURN_TOOL_CALL_CAP); // The two pending calls collected before the cap tripped are dropped, so @@ -8096,12 +8096,12 @@ hello }); it('should fire StopFailure hook on always-on loop detection', async () => { - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), getHistoryLength: vi.fn().mockReturnValue(0), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const fireStopFailureEvent = vi.fn().mockResolvedValue(undefined); vi.mocked(mockConfig.getDisableAllHooks).mockReturnValue(false); @@ -8139,12 +8139,12 @@ hello }); it('should fire StopFailure hook on heuristic loop detection', async () => { - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), getHistoryLength: vi.fn().mockReturnValue(0), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const fireStopFailureEvent = vi.fn().mockResolvedValue(undefined); vi.mocked(mockConfig.getDisableAllHooks).mockReturnValue(false); @@ -8182,12 +8182,12 @@ hello }); it('should pass undefined error_details when loopType is null', async () => { - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), getHistoryLength: vi.fn().mockReturnValue(0), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const fireStopFailureEvent = vi.fn().mockResolvedValue(undefined); vi.mocked(mockConfig.getDisableAllHooks).mockReturnValue(false); @@ -8223,12 +8223,12 @@ hello }); it('should not fire StopFailure hook on loop detection when hooks are disabled', async () => { - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), getHistoryLength: vi.fn().mockReturnValue(0), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const fireStopFailureEvent = vi.fn().mockResolvedValue(undefined); vi.mocked(mockConfig.getDisableAllHooks).mockReturnValue(true); @@ -8263,12 +8263,12 @@ hello }); it('should not fire StopFailure hook on loop detection when no StopFailure hooks configured', async () => { - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), getHistoryLength: vi.fn().mockReturnValue(0), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const fireStopFailureEvent = vi.fn().mockResolvedValue(undefined); vi.mocked(mockConfig.getDisableAllHooks).mockReturnValue(false); @@ -8303,12 +8303,12 @@ hello }); it('should swallow StopFailure hook rejection on loop detection', async () => { - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), getHistoryLength: vi.fn().mockReturnValue(0), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const fireStopFailureEvent = vi .fn() @@ -8372,7 +8372,7 @@ hello }) { for (const call of [distinctA, distinctB]) { this.pendingToolCalls.push(call); - yield { type: GeminiEventType.ToolCallRequest, value: call }; + yield { type: LlmEventType.ToolCallRequest, value: call }; } // TOOL_CALL_LOOP_THRESHOLD (5) identical calls trip the guard on the 5th. for (let i = 0; i < 5; i++) { @@ -8382,15 +8382,15 @@ hello args: { command: 'echo loop' }, }; this.pendingToolCalls.push(call); - yield { type: GeminiEventType.ToolCallRequest, value: call }; + yield { type: LlmEventType.ToolCallRequest, value: call }; } }); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const stream = client.sendMessageStream( [{ text: 'mix distinct then repeat' }], @@ -8409,7 +8409,7 @@ hello // Halts on the 5th identical call via the always-on consecutive guard. expect(events.at(-1)).toEqual({ - type: GeminiEventType.LoopDetected, + type: LlmEventType.LoopDetected, value: { loopType: LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS }, }); // The pending queue is fully cleared on halt, same as the turn cap. @@ -8430,11 +8430,11 @@ hello return new Promise(() => {}); // never settles }); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; mockTurnRunFn.mockReturnValue( (async function* () { yield { type: 'content', value: 'outer reply' }; @@ -8468,7 +8468,7 @@ hello prompt_id: 'test', }, }; - })() as unknown as AsyncGenerator, + })() as unknown as AsyncGenerator, ); const stream = client.sendMessageStream( @@ -8495,11 +8495,11 @@ hello })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const stream = client.sendMessageStream( [{ text: 'Quick question' }], @@ -8536,11 +8536,11 @@ hello })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const stream = client.sendMessageStream( [{ text: 'Quick question' }], @@ -8574,18 +8574,18 @@ hello }); const mockStream = (async function* () { - yield { type: GeminiEventType.Content, value: 'Done' }; + yield { type: LlmEventType.Content, value: 'Done' }; })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([ { role: 'user', parts: [{ text: 'I prefer terse responses.' }] }, { role: 'model', parts: [{ text: 'Done' }] }, ]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const events = await fromAsync( client.sendMessageStream( @@ -8609,7 +8609,7 @@ hello config: mockConfig, }); expect(events).not.toContainEqual({ - type: GeminiEventType.HookSystemMessage, + type: LlmEventType.HookSystemMessage, value: 'Managed auto-memory updated: user.md', }); }); @@ -8623,11 +8623,11 @@ hello })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const stream = client.sendMessageStream( [{ text: 'What day is it?' }], @@ -8665,7 +8665,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; await runWithAgentContext('agent-1', async () => { const stream = client.sendMessageStream( @@ -8694,7 +8694,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; const stream = client.sendMessageStream( [{ text: 'Plan this change' }], @@ -8721,7 +8721,7 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiChat; + } as unknown as LlmChat; const stream = client.sendMessageStream( [{ text: 'Plan this change' }], @@ -8744,11 +8744,11 @@ hello })(); mockTurnRunFn.mockReturnValue(mockStream1); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; // First query on June 5 — should inject date const stream1 = client.sendMessageStream( @@ -8805,11 +8805,11 @@ hello })(); mockTurnRunFn.mockReturnValue(mockStream1); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; // First query on June 4 — should inject date const stream1 = client.sendMessageStream( @@ -8869,11 +8869,11 @@ hello })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; // Send a Cron message — date should NOT be injected const stream = client.sendMessageStream( @@ -8924,12 +8924,12 @@ hello describe('autoSkill: scheduleSkillReview via runManagedAutoMemoryBackgroundTasks', () => { let mockStreamFn: () => AsyncGenerator<{ type: string; value: string }>; - let mockChat: Partial; + let mockChat: Partial; beforeEach(() => { vi.spyOn(client['config'], 'getAutoSkillEnabled').mockReturnValue(true); mockStreamFn = async function* () { - yield { type: GeminiEventType.Content, value: 'Done' }; + yield { type: LlmEventType.Content, value: 'Done' }; }; mockTurnRunFn.mockReturnValue(mockStreamFn()); mockChat = { @@ -8939,7 +8939,7 @@ hello { role: 'model', parts: [{ text: 'Done' }] }, ]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; }); it('should call scheduleSkillReview with correct params on UserQuery', async () => { @@ -9150,11 +9150,11 @@ hello })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const initialRequest = [{ text: 'Hi' }]; @@ -9188,11 +9188,11 @@ Other open files: })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; // Act const stream = client.sendMessageStream( @@ -9232,11 +9232,11 @@ Other open files: })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; // Use a signal that never gets aborted const abortController = new AbortController(); @@ -9319,11 +9319,11 @@ Other open files: })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; // Act & Assert // Run up to the limit @@ -9351,7 +9351,7 @@ Other open files: events.push(event); } - expect(events).toEqual([{ type: GeminiEventType.MaxSessionTurns }]); + expect(events).toEqual([{ type: LlmEventType.MaxSessionTurns }]); expect(mockTurnRunFn).toHaveBeenCalledTimes(MAX_SESSION_TURNS); }); @@ -9370,11 +9370,11 @@ Other open files: })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const stream = client.sendMessageStream( [{ text: 'over the limit' }], @@ -9386,7 +9386,7 @@ Other open files: events.push(event); } - expect(events).toEqual([{ type: GeminiEventType.MaxSessionTurns }]); + expect(events).toEqual([{ type: LlmEventType.MaxSessionTurns }]); expect(abortHandler).toHaveBeenCalledTimes(1); }); @@ -9410,12 +9410,12 @@ Other open files: })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), getLastPromptTokenCount: vi.fn().mockReturnValue(9999), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const stream = client.sendMessageStream( [{ text: 'token limit test' }], @@ -9429,7 +9429,7 @@ Other open files: expect(events).toEqual([ { - type: GeminiEventType.SessionTokenLimitExceeded, + type: LlmEventType.SessionTokenLimitExceeded, value: expect.objectContaining({ currentTokens: 9999, limit: 1, @@ -9459,11 +9459,11 @@ Other open files: })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; // Use a signal that never gets aborted const abortController = new AbortController(); @@ -9533,7 +9533,7 @@ Other open files: vi.spyOn(client['config'], 'getIdeMode').mockReturnValue(true); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), setHistory: vi.fn(), // Assume history is not empty for delta checks @@ -9543,7 +9543,7 @@ Other open files: { role: 'user', parts: [{ text: 'previous message' }] }, ]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; }); const testCases = [ @@ -9766,7 +9766,7 @@ Other open files: }); describe('IDE context with pending tool calls', () => { - let mockChat: Partial; + let mockChat: Partial; beforeEach(() => { vi.spyOn(client, 'tryCompressChat').mockResolvedValue({ @@ -9785,7 +9785,7 @@ Other open files: getHistory: vi.fn().mockReturnValue([]), // Default empty history setHistory: vi.fn(), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; vi.spyOn(client['config'], 'getIdeMode').mockReturnValue(true); vi.mocked(ideContextStore.get).mockReturnValue({ @@ -9991,7 +9991,7 @@ Other open files: mockTurnRunFn.mockReturnValueOnce( (async function* () { yield { - type: GeminiEventType.Error, + type: LlmEventType.Error, value: new Error('network failed'), }; })(), @@ -10010,7 +10010,7 @@ Other open files: mockTurnRunFn.mockReturnValueOnce( (async function* () { - yield { type: GeminiEventType.Content, value: 'ok' }; + yield { type: LlmEventType.Content, value: 'ok' }; })(), ); @@ -10070,7 +10070,7 @@ Other open files: signal: AbortSignal, ) { if (signal.aborted) { - yield { type: GeminiEventType.UserCancelled }; + yield { type: LlmEventType.UserCancelled }; } throw new UnauthorizedError('unauthorized'); }); @@ -10089,7 +10089,7 @@ Other open files: mockTurnRunFn.mockReturnValueOnce( (async function* () { - yield { type: GeminiEventType.Content, value: 'ok' }; + yield { type: LlmEventType.Content, value: 'ok' }; })(), ); @@ -10360,17 +10360,17 @@ Other open files: const mockStream = (async function* () { yield { - type: GeminiEventType.Error, + type: LlmEventType.Error, value: { error: { message: 'test error' } }, }; })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; // Act const stream = client.sendMessageStream( @@ -10410,7 +10410,7 @@ Other open files: mockTurnRunFn.mockReturnValue( (async function* () { yield { - type: GeminiEventType.Error, + type: LlmEventType.Error, value: { error: { message: 'Bearer secret-token in /private/user/path', @@ -10464,7 +10464,7 @@ Other open files: mockTurnRunFn.mockReturnValue( (async function* () { yield { - type: GeminiEventType.Error, + type: LlmEventType.Error, value: { error: { message: 'provider failed', status: 500 }, }, @@ -10482,7 +10482,7 @@ Other open files: expect(events).toEqual([ { - type: GeminiEventType.Error, + type: LlmEventType.Error, value: { error: { message: 'provider failed', status: 500 }, }, @@ -10573,19 +10573,19 @@ Other open files: const mockCheckNextSpeaker = vi.mocked(checkNextSpeaker); const mockStream = (async function* () { - yield { type: GeminiEventType.Content, value: 'some content' }; + yield { type: LlmEventType.Content, value: 'some content' }; yield { - type: GeminiEventType.Error, + type: LlmEventType.Error, value: { error: { message: 'test error' } }, }; })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; // Act const stream = client.sendMessageStream( @@ -10621,11 +10621,11 @@ Other open files: })(); mockTurnRunFn.mockReturnValue(mockStream); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; // Act const stream = client.sendMessageStream( @@ -10649,7 +10649,7 @@ Other open files: (async function* () { for (let i = 0; i < 5; i++) { yield { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: `repeat-${i}`, name: 'run_shell_command', @@ -10660,11 +10660,11 @@ Other open files: })(), ); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const events = await fromAsync( client.sendMessageStream( @@ -10678,7 +10678,7 @@ Other open files: // regardless of skipLoopDetection so the DashScope server never sees // enough repeats to reject the conversation (issue #5019). expect(events.at(-1)).toEqual({ - type: GeminiEventType.LoopDetected, + type: LlmEventType.LoopDetected, value: { loopType: LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS }, }); }); @@ -10690,7 +10690,7 @@ Other open files: (async function* () { for (let i = 0; i < 5; i++) { yield { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: `repeat-${i}`, name: 'run_shell_command', @@ -10701,11 +10701,11 @@ Other open files: })(), ); - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const events = await fromAsync( client.sendMessageStream( @@ -10716,7 +10716,7 @@ Other open files: ); expect(events.at(-1)).toEqual({ - type: GeminiEventType.LoopDetected, + type: LlmEventType.LoopDetected, value: { loopType: LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS }, }); expect(events).toHaveLength(5); @@ -10724,7 +10724,7 @@ Other open files: describe('retry sendMessageType', () => { it('should call stripOrphanedUserEntriesFromHistory before executing', async () => { - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), getHistoryLength: vi.fn().mockReturnValueOnce(3).mockReturnValue(2), @@ -10732,7 +10732,7 @@ Other open files: stripOrphanedUserEntriesFromHistory: vi.fn(), repairOrphanedToolUseTurns: vi.fn().mockReturnValue({ injected: [] }), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const mockStream = (async function* () { yield { type: 'content', value: 'retry response' }; @@ -10761,7 +10761,7 @@ Other open files: role: 'user', parts: [{ text: 'retry me' }], }; - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), getHistoryLength: vi.fn().mockReturnValue(0), @@ -10773,11 +10773,11 @@ Other open files: .mockReturnValue([orphanedPrompt]), repairOrphanedToolUseTurns: vi.fn().mockReturnValue({ injected: [] }), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; mockTurnRunFn.mockReturnValue( (async function* () { - yield* [] as ServerGeminiStreamEvent[]; + yield* [] as ServerLlmStreamEvent[]; throw new Error('retry failed before first event'); })(), ); @@ -10806,10 +10806,10 @@ Other open files: role: 'user', parts: [{ text: 'retry me' }], }; - // Mirror GeminiChat's user-content push counter; the mocked turn bumps + // Mirror LlmChat's user-content push counter; the mocked turn bumps // it when it simulates the pre-API push. let pushCount = 0; - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), getHistoryLength: vi.fn().mockReturnValue(0), @@ -10820,14 +10820,14 @@ Other open files: .mockReturnValue([orphanedPrompt]), repairOrphanedToolUseTurns: vi.fn().mockReturnValue({ injected: [] }), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; mockTurnRunFn.mockReturnValue( (async function* () { // Simulate the real chat pushing the re-submitted user content into // history before the API call, then failing pre-event. pushCount++; - yield* [] as ServerGeminiStreamEvent[]; + yield* [] as ServerLlmStreamEvent[]; throw new Error('retry failed after push, before first event'); })(), ); @@ -10867,7 +10867,7 @@ Other open files: { role: 'user', parts: [{ text: 'old-3' }] }, ]; let pushCount = 0; - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn(() => historyRef), getHistoryLength: vi.fn(() => historyRef.length), @@ -10878,7 +10878,7 @@ Other open files: .mockReturnValue([orphanedPrompt]), repairOrphanedToolUseTurns: vi.fn().mockReturnValue({ injected: [] }), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; mockTurnRunFn.mockReturnValue( (async function* () { @@ -10889,7 +10889,7 @@ Other open files: historyRef.push({ role: 'user', parts: [{ text: 'summary' }] }); historyRef.push(orphanedPrompt); pushCount++; - yield* [] as ServerGeminiStreamEvent[]; + yield* [] as ServerLlmStreamEvent[]; throw new Error( 'failed after compression+push, before first event', ); @@ -10914,7 +10914,7 @@ Other open files: }); it('should not increment sessionTurnCount for retry', async () => { - const mockChat: Partial = { + const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), getHistoryLength: vi.fn().mockReturnValue(0), @@ -10922,7 +10922,7 @@ Other open files: stripOrphanedUserEntriesFromHistory: vi.fn(), repairOrphanedToolUseTurns: vi.fn().mockReturnValue({ injected: [] }), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; const mockStream = (async function* () { yield { type: 'content', value: 'ok' }; @@ -10946,7 +10946,7 @@ Other open files: }); describe('hooks fast-path optimization', () => { - let mockChat: Partial; + let mockChat: Partial; beforeEach(() => { vi.spyOn(client, 'tryCompressChat').mockResolvedValue({ @@ -10964,7 +10964,7 @@ Other open files: addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), }; - client['chat'] = mockChat as GeminiChat; + client['chat'] = mockChat as LlmChat; }); it('emits active_goal when a goal is active for the turn', async () => { @@ -10986,7 +10986,7 @@ Other open files: ); expect(events[0]).toEqual({ - type: GeminiEventType.ActiveGoal, + type: LlmEventType.ActiveGoal, value: { condition: 'finish the refactor', iterations: 2, @@ -11029,10 +11029,10 @@ Other open files: parts: [{ text: 'done' }], }, ]), - } as unknown as GeminiChat; + } as unknown as LlmChat; mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'done' }; + yield { type: LlmEventType.Content, value: 'done' }; })(), ); @@ -11045,7 +11045,7 @@ Other open files: ); expect(events).toContainEqual({ - type: GeminiEventType.ActiveGoal, + type: LlmEventType.ActiveGoal, value: null, }); }); @@ -11083,10 +11083,10 @@ Other open files: parts: [{ text: 'done' }], }, ]), - } as unknown as GeminiChat; + } as unknown as LlmChat; mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'done' }; + yield { type: LlmEventType.Content, value: 'done' }; })(), ); @@ -11099,18 +11099,18 @@ Other open files: ); const activeGoalEvents = events.filter( - (event) => event.type === GeminiEventType.ActiveGoal, + (event) => event.type === LlmEventType.ActiveGoal, ); expect(activeGoalEvents).toEqual([ { - type: GeminiEventType.ActiveGoal, + type: LlmEventType.ActiveGoal, value: expect.objectContaining({ condition: 'finish the refactor', }), }, { - type: GeminiEventType.ActiveGoal, + type: LlmEventType.ActiveGoal, value: null, }, ]); @@ -11164,10 +11164,10 @@ Other open files: parts: [{ text: 'done' }], }, ]), - } as unknown as GeminiChat; + } as unknown as LlmChat; mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'done' }; + yield { type: LlmEventType.Content, value: 'done' }; })(), ); @@ -11179,19 +11179,19 @@ Other open files: ), ); const activeGoalEvents = events.filter( - (event) => event.type === GeminiEventType.ActiveGoal, + (event) => event.type === LlmEventType.ActiveGoal, ); expect(activeGoalEvents).toEqual([ { - type: GeminiEventType.ActiveGoal, + type: LlmEventType.ActiveGoal, value: expect.objectContaining({ condition: 'finish the refactor', iterations: 2, }), }, { - type: GeminiEventType.ActiveGoal, + type: LlmEventType.ActiveGoal, value: expect.objectContaining({ condition: 'finish the refactor', iterations: 3, @@ -11201,7 +11201,7 @@ Other open files: ]); expect(events).not.toContainEqual( expect.objectContaining({ - type: GeminiEventType.StopHookLoop, + type: LlmEventType.StopHookLoop, }), ); }); @@ -11292,8 +11292,8 @@ Other open files: ); mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'Hello, ' }; - yield { type: GeminiEventType.Content, value: 'world.' }; + yield { type: LlmEventType.Content, value: 'Hello, ' }; + yield { type: LlmEventType.Content, value: 'world.' }; })(), ); @@ -11342,9 +11342,9 @@ Other open files: }); mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'Hello, ' }; + yield { type: LlmEventType.Content, value: 'Hello, ' }; await secondChunkGate; - yield { type: GeminiEventType.Content, value: 'world.' }; + yield { type: LlmEventType.Content, value: 'world.' }; })(), ); @@ -11413,7 +11413,7 @@ Other open files: vi.mocked(mockConfig.getDebugLogger).mockReturnValue(debugLogger); mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'Hello, world.' }; + yield { type: LlmEventType.Content, value: 'Hello, world.' }; })(), ); @@ -11466,7 +11466,7 @@ Other open files: ); mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'Hello, world.' }; + yield { type: LlmEventType.Content, value: 'Hello, world.' }; })(), ); @@ -11517,7 +11517,7 @@ Other open files: mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'Hello, world.' }; + yield { type: LlmEventType.Content, value: 'Hello, world.' }; })(), ); @@ -11562,7 +11562,7 @@ Other open files: mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'Hello, world.' }; + yield { type: LlmEventType.Content, value: 'Hello, world.' }; })(), ); @@ -11598,9 +11598,9 @@ Other open files: mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'Hello, world.' }; + yield { type: LlmEventType.Content, value: 'Hello, world.' }; yield { - type: GeminiEventType.Error, + type: LlmEventType.Error, value: { error: { message: 'test error' } }, }; })(), @@ -11639,7 +11639,7 @@ Other open files: const controller = new AbortController(); mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'Hello, world.' }; + yield { type: LlmEventType.Content, value: 'Hello, world.' }; controller.abort(); })(), ); @@ -11675,7 +11675,7 @@ Other open files: mockTurnRunFn.mockReturnValue( (async function* () { yield { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: '1', name: 'read_file', @@ -11730,10 +11730,10 @@ Other open files: parts: [{ text: 'not done' }], }, ]), - } as unknown as GeminiChat; + } as unknown as LlmChat; mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'not done' }; + yield { type: LlmEventType.Content, value: 'not done' }; })(), ); @@ -11748,11 +11748,11 @@ Other open files: expect(mockTurnRunFn).toHaveBeenCalledTimes(1); expect(events).not.toContainEqual( expect.objectContaining({ - type: GeminiEventType.StopHookLoop, + type: LlmEventType.StopHookLoop, }), ); expect(events).toContainEqual({ - type: GeminiEventType.HookSystemMessage, + type: LlmEventType.HookSystemMessage, value: 'Stop hook blocked continuation 1 consecutive time; overriding and ending the turn.', }); @@ -11791,14 +11791,14 @@ Other open files: .mockReturnValue([ { role: 'model', parts: [{ text: 'not done' }] }, ]), - } as unknown as GeminiChat; + } as unknown as LlmChat; let turnIndex = 0; mockTurnRunFn.mockImplementation(() => { const turnNo = turnIndex++; return (async function* () { for (let i = 0; i < 3; i++) { yield { - type: GeminiEventType.ToolCallRequest, + type: LlmEventType.ToolCallRequest, value: { callId: `call-${turnNo}-${i}`, name: 'test_tool', @@ -11808,7 +11808,7 @@ Other open files: }, }; } - yield { type: GeminiEventType.Content, value: 'not done' }; + yield { type: LlmEventType.Content, value: 'not done' }; })(); }); @@ -11826,7 +11826,7 @@ Other open files: // continuation started a fresh budget instead of inheriting the // first turn's accumulated count. expect(events).not.toContainEqual( - expect.objectContaining({ type: GeminiEventType.LoopDetected }), + expect.objectContaining({ type: LlmEventType.LoopDetected }), ); expect( mockInteractionTelemetry.startInteractionSpan, @@ -11872,10 +11872,10 @@ Other open files: parts: [{ text: 'not done' }], }, ]), - } as unknown as GeminiChat; + } as unknown as LlmChat; mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'not done' }; + yield { type: LlmEventType.Content, value: 'not done' }; })(), ); @@ -11887,18 +11887,18 @@ Other open files: ), ); const activeGoalEvents = events.filter( - (event) => event.type === GeminiEventType.ActiveGoal, + (event) => event.type === LlmEventType.ActiveGoal, ); expect(activeGoalEvents).toEqual([ { - type: GeminiEventType.ActiveGoal, + type: LlmEventType.ActiveGoal, value: expect.objectContaining({ condition: 'finish the refactor', }), }, { - type: GeminiEventType.ActiveGoal, + type: LlmEventType.ActiveGoal, value: null, }, ]); @@ -12070,7 +12070,7 @@ Other open files: } as unknown as ReturnType); mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'ok' }; + yield { type: LlmEventType.Content, value: 'ok' }; })(), ); @@ -12124,7 +12124,7 @@ Other open files: ); mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'ok' }; + yield { type: LlmEventType.Content, value: 'ok' }; })(), ); @@ -12178,7 +12178,7 @@ Other open files: .mockImplementation(() => {}); mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'ok' }; + yield { type: LlmEventType.Content, value: 'ok' }; })(), ); @@ -12267,7 +12267,7 @@ Other open files: const sendSpy = vi.spyOn(client, 'sendMessageStream'); mockTurnRunFn.mockImplementation(() => (async function* () { - yield { type: GeminiEventType.Content, value: 'response' }; + yield { type: LlmEventType.Content, value: 'response' }; })(), ); const getSteerInput = vi @@ -12339,7 +12339,7 @@ Other open files: ); mockTurnRunFn.mockImplementation(() => (async function* () { - yield { type: GeminiEventType.Content, value: 'response' }; + yield { type: LlmEventType.Content, value: 'response' }; })(), ); const getSteerInput = vi @@ -12387,7 +12387,7 @@ Other open files: ); mockTurnRunFn.mockImplementation(() => (async function* () { - yield { type: GeminiEventType.Content, value: 'response' }; + yield { type: LlmEventType.Content, value: 'response' }; })(), ); const getSteerInput = vi @@ -12451,7 +12451,7 @@ Other open files: ); mockTurnRunFn.mockImplementation(() => (async function* () { - yield { type: GeminiEventType.Content, value: 'response' }; + yield { type: LlmEventType.Content, value: 'response' }; })(), ); const getSteerInput = vi @@ -12507,7 +12507,7 @@ Other open files: ); mockTurnRunFn.mockImplementation(() => (async function* () { - yield { type: GeminiEventType.Content, value: 'response' }; + yield { type: LlmEventType.Content, value: 'response' }; })(), ); const getSteerInput = vi @@ -12529,7 +12529,7 @@ Other open files: expect(mockTurnRunFn).toHaveBeenCalledOnce(); expect(events).toContainEqual({ - type: GeminiEventType.ActiveGoal, + type: LlmEventType.ActiveGoal, value: null, }); }); @@ -12568,7 +12568,7 @@ Other open files: ); mockTurnRunFn.mockImplementation(() => (async function* () { - yield { type: GeminiEventType.Content, value: 'response' }; + yield { type: LlmEventType.Content, value: 'response' }; })(), ); const getSteerInput = vi @@ -12639,7 +12639,7 @@ Other open files: ); mockTurnRunFn.mockImplementation(() => (async function* () { - yield { type: GeminiEventType.Content, value: 'response' }; + yield { type: LlmEventType.Content, value: 'response' }; })(), ); const getSteerInput = vi @@ -12680,7 +12680,7 @@ Other open files: .mockResolvedValue(null); mockTurnRunFn.mockImplementation(() => (async function* () { - yield { type: GeminiEventType.Content, value: 'response' }; + yield { type: LlmEventType.Content, value: 'response' }; })(), ); const getSteerInput = vi @@ -12719,7 +12719,7 @@ Other open files: it('does not drain steer input without another model-turn budget', async () => { mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'response' }; + yield { type: LlmEventType.Content, value: 'response' }; })(), ); const getSteerInput = vi.fn<() => Promise>(); @@ -12742,7 +12742,7 @@ Other open files: mockTurnRunFn .mockImplementationOnce(() => (async function* () { - yield { type: GeminiEventType.Content, value: 'response' }; + yield { type: LlmEventType.Content, value: 'response' }; })(), ) .mockImplementationOnce(() => { @@ -12777,7 +12777,7 @@ Other open files: mockTurnRunFn.mockImplementation(() => { pushCount = 1; return (async function* () { - yield { type: GeminiEventType.Content, value: 'response' }; + yield { type: LlmEventType.Content, value: 'response' }; })(); }); const accept = vi.fn(); @@ -12809,8 +12809,8 @@ Other open files: mockTurnRunFn.mockImplementation(() => { pushCount = 1; return (async function* () { - yield { type: GeminiEventType.Content, value: 'first' }; - yield { type: GeminiEventType.Content, value: 'second' }; + yield { type: LlmEventType.Content, value: 'first' }; + yield { type: LlmEventType.Content, value: 'second' }; })(); }); const accept = vi.fn(); @@ -12970,7 +12970,7 @@ Other open files: pushCount = turnCall; return (async function* () { yield { - type: GeminiEventType.Content, + type: LlmEventType.Content, value: `response ${turnCall}`, }; })(); @@ -13040,7 +13040,7 @@ Other open files: pushCount = turnCall; return (async function* () { yield { - type: GeminiEventType.Content, + type: LlmEventType.Content, value: `response ${turnCall}`, }; })(); @@ -13176,7 +13176,7 @@ Other open files: mockTurnRunFn.mockReturnValue( (async function* () { - yield { type: GeminiEventType.Content, value: 'ok' }; + yield { type: LlmEventType.Content, value: 'ok' }; })(), ); }); @@ -13184,14 +13184,14 @@ Other open files: async function collectStream( messageType: SendMessageType, promptId = 'prompt-uq', - ): Promise { + ): Promise { const stream = client.sendMessageStream( [{ text: 'user' }], new AbortController().signal, promptId, { type: messageType }, ); - const chunks: ServerGeminiStreamEvent[] = []; + const chunks: ServerLlmStreamEvent[] = []; for await (const chunk of stream) { chunks.push(chunk); } @@ -13227,7 +13227,7 @@ Other open files: const chunks = await collectStream(SendMessageType.UserQuery); expect(chunks).toContainEqual({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'ok', }); }); @@ -13240,7 +13240,7 @@ Other open files: const chunks = await collectStream(SendMessageType.UserQuery); expect(chunks).toContainEqual({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'ok', }); }); @@ -14525,7 +14525,7 @@ Other open files: .mockReturnValue({ status: 'skipped', skippedReason: 'disabled' }), }; - const client = new GeminiClient(makeMockConfigForShutdown(mgr)); + const client = new LlmClient(makeMockConfigForShutdown(mgr)); // Avoid needing a real chat — the method calls getHistoryShallow(). ( client as unknown as { getHistoryShallow: () => unknown[] } @@ -14566,7 +14566,7 @@ Other open files: .mockReturnValue({ status: 'skipped', skippedReason: 'disabled' }), }; const cfg = makeMockConfigForShutdown(mgr); - const client = new GeminiClient(cfg); + const client = new LlmClient(cfg); // Should not throw on first call expect(() => client.requestShutdown()).not.toThrow(); @@ -14775,7 +14775,7 @@ function makeMockConfigForShutdown( ): Config { return { isBareMode: vi.fn().mockReturnValue(false), - getGeminiClient: vi.fn().mockReturnValue(undefined), + getLlmClient: vi.fn().mockReturnValue(undefined), getProjectRoot: vi.fn().mockReturnValue('/project'), getSessionId: vi.fn().mockReturnValue('session-1'), getMemoryManager: vi.fn().mockReturnValue(mgr), diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 54453872dea..975310e30af 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -56,7 +56,7 @@ import { createSessionStartProfiler } from './session-start-profiler.js'; const debugLogger = createDebugLogger('CLIENT'); // Core modules -import { GeminiChat, type RepairOrphanedToolUseOptions } from './geminiChat.js'; +import { LlmChat, type RepairOrphanedToolUseOptions } from './llm-chat.js'; import { restorableAskUserQuestionCallIds } from './ask-user-question-restore.js'; import { getRecentGitStatus } from '../utils/gitUtils.js'; import { @@ -69,10 +69,10 @@ import { } from './prompts.js'; import { CompressionStatus, - GeminiEventType, + LlmEventType, Turn, type ChatCompressionInfo, - type ServerGeminiStreamEvent, + type ServerLlmStreamEvent, } from './turn.js'; // Services @@ -259,16 +259,13 @@ function sameGoalPermit( } type ActiveGoalEventValue = Exclude< - Extract< - ServerGeminiStreamEvent, - { type: GeminiEventType.ActiveGoal } - >['value'], + Extract['value'], null >; type GoalStateStreamEvent = Extract< - ServerGeminiStreamEvent, - { type: GeminiEventType.GoalState } + ServerLlmStreamEvent, + { type: LlmEventType.GoalState } >; function projectActiveGoal( @@ -372,8 +369,8 @@ export function getMainSessionBaseSystemPrompt( ); } -export class GeminiClient { - private chat?: GeminiChat; +export class LlmClient { + private chat?: LlmChat; private initializedSessionId: string | undefined; private sessionTurnCount = 0; private toolCallCount = 0; @@ -431,7 +428,7 @@ export class GeminiClient { snapshotEntries: AvailableSkillEntry[], ): void { this.announcedSkillReminderKeys = new Set( - snapshotEntries.map(GeminiClient.skillEntryKey), + snapshotEntries.map(LlmClient.skillEntryKey), ); this.skillRemindersInitialized = true; } @@ -592,7 +589,7 @@ export class GeminiClient { this.getChat().addHistory(content); } - getChat(): GeminiChat { + getChat(): LlmChat { if (!this.chat) { throw new Error('Chat not initialized'); } @@ -667,7 +664,7 @@ export class GeminiClient { /** * Fire-and-forget StopFailure hook for loop-detection early returns. * Matches the detached pattern used by the CLI's API-error path - * (useGeminiStream.ts) — output and errors are ignored. + * (use-llm-stream.ts) — output and errors are ignored. */ private fireLoopDetectedStopFailure(loopType: string | null): void { if (this.config.getDisableAllHooks()) return; @@ -683,13 +680,13 @@ export class GeminiClient { /** * Walk-only accessor for the set of `functionResponse.id` strings in * raw history. Callers that only need the dedup id set (notably - * `useGeminiStream.handleCompletedTools`) MUST prefer this over + * `useLlmStream.handleCompletedTools`) MUST prefer this over * {@link getHistory}, which deep-clones the entire conversation via * `structuredClone` on every call. On long sessions with sizable * tool outputs the clone is a multi-millisecond hit on the React UI * thread; running it on every tool-completion batch caused visible * frame drops during streaming. See - * `GeminiChat.getHistoryFunctionResponseIds` for the implementation. + * `LlmChat.getHistoryFunctionResponseIds` for the implementation. */ getHistoryFunctionResponseIds(): Set { return this.getChat().getHistoryFunctionResponseIds(); @@ -699,7 +696,7 @@ export class GeminiClient { * Walk-only accessor for the handled tool-call id → (name, args) * fingerprint map used by duplicate provider-id replay detection. Same * no-clone rationale as {@link getHistoryFunctionResponseIds}. See - * `GeminiChat.getHistoryToolCallFingerprints` for the implementation. + * `LlmChat.getHistoryToolCallFingerprints` for the implementation. */ getHistoryToolCallFingerprints(): Map { return this.getChat().getHistoryToolCallFingerprints(); @@ -751,7 +748,7 @@ export class GeminiClient { * {@link stripOrphanedUserEntriesFromHistory}, which only handles trailing * `user` entries. * - * This `GeminiClient` method is the resume-path entry point — called once + * This `LlmClient` method is the resume-path entry point — called once * from {@link startChat} after the transcript loads, covering `--resume` * of a session that crashed between a partial-tool_use push and the * tool's eventual completion. @@ -759,14 +756,14 @@ export class GeminiClient { * The other two coverage points (Retry submit path after * `stripOrphanedUserEntriesFromHistory`, and the defensive pass at the * start of every UserQuery / Cron send) live one layer down inside - * `GeminiChat.sendMessageStream` and call the standalone + * `LlmChat.sendMessageStream` and call the standalone * `repairOrphanedToolUseTurns(history)` function directly — they don't * route through this wrapper. Anyone tracing the repair-pass coupling * between the client and chat layers should follow that path * separately rather than expect everything to funnel through here. * * Synthesizes an `error` `functionResponse`. The React tool scheduler - * (`useGeminiStream.handleCompletedTools`) MUST dedupe by `callId` against + * (`useLlmStream.handleCompletedTools`) MUST dedupe by `callId` against * the live history before submitting its own `tool_result` — otherwise a * late real result lands as a second `user[tool_result]` block (orphan * because the synthetic already consumed the matching `tool_use`). @@ -1241,7 +1238,7 @@ export class GeminiClient { /** * Re-prepend a fresh startup-context prelude after auto-compaction. * - * Auto-compaction runs in-place inside `GeminiChat.sendMessageStream` + * Auto-compaction runs in-place inside `LlmChat.sendMessageStream` * (`setHistory([summary, ack, ...kept])`) and does NOT route through * `tryCompressChat` → `startChat`, so — unlike manual `/compress` — the * startup prelude at history[0] is consumed into the summary and never @@ -1566,7 +1563,7 @@ export class GeminiClient { return; } - const currentKeys = new Set(entries.map(GeminiClient.skillEntryKey)); + const currentKeys = new Set(entries.map(LlmClient.skillEntryKey)); const wasInitialized = this.skillRemindersInitialized; const removedNames: string[] = []; @@ -1608,7 +1605,7 @@ export class GeminiClient { // by coreToolScheduler above. const newEntries: AvailableSkillEntry[] = []; for (const entry of entries) { - const key = GeminiClient.skillEntryKey(entry); + const key = LlmClient.skillEntryKey(entry); if (this.announcedSkillReminderKeys.has(key)) { continue; } @@ -1732,7 +1729,7 @@ export class GeminiClient { sessionStartSource = extraHistory ? SessionStartSource.Resume : SessionStartSource.Startup, - ): Promise { + ): Promise { this.forceFullIdeContext = true; this.lastInjectedDate = undefined; // Clear stale cache params on session reset to prevent cross-session leakage @@ -1805,7 +1802,7 @@ export class GeminiClient { const chat = profiler.timeSync( 'gemini_chat_construct', () => - new GeminiChat( + new LlmChat( this.config, { systemInstruction, @@ -2345,7 +2342,7 @@ export class GeminiClient { prompt_id: string, options?: SendMessageOptions, turns: number = MAX_TURNS, - ): AsyncGenerator { + ): AsyncGenerator { const messageType = options?.type ?? SendMessageType.UserQuery; const startsInteraction = messageType === SendMessageType.UserQuery || @@ -2429,18 +2426,18 @@ export class GeminiClient { if (unsubscribeGoalState) return; unsubscribeGoalState = runtime.subscribe((value, cause) => { pendingGoalStateEvents.push({ - type: GeminiEventType.GoalState, + type: LlmEventType.GoalState, value, ...(cause !== undefined ? { cause } : {}), }); }); pendingGoalStateEvents.push({ - type: GeminiEventType.GoalState, + type: LlmEventType.GoalState, value: runtime.getSnapshot(), }); }; - const takePendingGoalEvents = (): ServerGeminiStreamEvent[] => { - const events: ServerGeminiStreamEvent[] = []; + const takePendingGoalEvents = (): ServerLlmStreamEvent[] => { + const events: ServerLlmStreamEvent[] = []; for (const stateEvent of pendingGoalStateEvents.splice( 0, pendingGoalStateEvents.length, @@ -2452,7 +2449,7 @@ export class GeminiClient { lastEmittedActiveGoal = nextActiveGoal; if (nextActiveGoal) { events.push({ - type: GeminiEventType.ActiveGoal, + type: LlmEventType.ActiveGoal, value: nextActiveGoal, }); } @@ -2461,7 +2458,7 @@ export class GeminiClient { ) { lastEmittedActiveGoal = nextActiveGoal; events.push({ - type: GeminiEventType.ActiveGoal, + type: LlmEventType.ActiveGoal, value: nextActiveGoal ?? null, }); } @@ -2541,7 +2538,7 @@ export class GeminiClient { return takePendingGoalEvents(); }; let strippedRetryEntries: Content[] = []; - // Snapshot of GeminiChat's user-content push counter, taken right after the + // Snapshot of LlmChat's user-content push counter, taken right after the // strip. The Retry's re-submitted content is the first thing the send // pushes, so if the counter advances at all that content landed. let pushCountAfterStrip = 0; @@ -2720,7 +2717,7 @@ export class GeminiClient { endCurrentInteraction('cancelled'); } yield { - type: GeminiEventType.UserPromptSubmitBlocked, + type: LlmEventType.UserPromptSubmitBlocked, value: { reason: hookOutput.getEffectiveReason(), originalPrompt: promptText, @@ -3104,7 +3101,7 @@ export class GeminiClient { this.sessionTurnCount > this.config.getMaxSessionTurns() ) { this.cancelPendingMemoryPrefetch('no_safe_delivery_point'); - yield { type: GeminiEventType.MaxSessionTurns }; + yield { type: LlmEventType.MaxSessionTurns }; endCurrentInteraction( 'error', 'max session turns exceeded', @@ -3155,7 +3152,7 @@ export class GeminiClient { return steerInput; }; - // Auto-compaction happens inside GeminiChat.sendMessageStream and surfaces + // Auto-compaction happens inside LlmChat.sendMessageStream and surfaces // via the `compressed → ChatCompressed` bridge in turn.ts. Manual /compress // still calls tryCompressChat directly for the full reset (env refresh + // forceFullIdeContext flip). @@ -3163,10 +3160,10 @@ export class GeminiClient { const sessionTokenLimit = this.config.getSessionTokenLimit(); if (sessionTokenLimit > 0) { // An exact `\0` full-turn route selector resolves to its route before - // GeminiChat.sendMessageStream stamps counts under it, so the gate + // LlmChat.sendMessageStream stamps counts under it, so the gate // must key the resolved route too — the raw selector key can never // match a stamped count. Mirrors the resolution at the top of - // GeminiChat.sendMessageStream (#9454). + // LlmChat.sendMessageStream (#9454). const exactRoute = model.endsWith('\0') ? await this.config .getBaseLlmClient() @@ -3181,7 +3178,7 @@ export class GeminiClient { if (lastPromptTokenCount > sessionTokenLimit) { this.cancelPendingMemoryPrefetch('no_safe_delivery_point'); yield { - type: GeminiEventType.SessionTokenLimitExceeded, + type: LlmEventType.SessionTokenLimitExceeded, value: { currentTokens: lastPromptTokenCount, limit: sessionTokenLimit, @@ -3428,7 +3425,7 @@ export class GeminiClient { : getActiveGoal(this.config.getSessionId()); if (activeGoalAtTurnStart) { yield { - type: GeminiEventType.ActiveGoal, + type: LlmEventType.ActiveGoal, value: activeGoalAtTurnStart, }; } @@ -3437,13 +3434,13 @@ export class GeminiClient { // Mutates `lastEmittedActiveGoal` when an event is returned. const maybeEmitActiveGoalChange = ( nextActiveGoal: ActiveGoal | undefined, - ): ServerGeminiStreamEvent | undefined => { + ): ServerLlmStreamEvent | undefined => { if (activeGoalEquals(lastEmittedActiveGoal, nextActiveGoal)) { return undefined; } lastEmittedActiveGoal = nextActiveGoal; return { - type: GeminiEventType.ActiveGoal, + type: LlmEventType.ActiveGoal, value: nextActiveGoal ?? null, }; }; @@ -3488,24 +3485,24 @@ export class GeminiClient { settleSteerInput(attachedSteerInput, attachedSteerPushCount); steerInputSettled = true; } - if (event.type === GeminiEventType.ToolCallRequest) { + if (event.type === LlmEventType.ToolCallRequest) { hasToolCalls = true; } else if ( - event.type === GeminiEventType.Retry || - event.type === GeminiEventType.ModelFallback + event.type === LlmEventType.Retry || + event.type === LlmEventType.ModelFallback ) { hasToolCalls = false; agentOutput.restartAttempt( - event.type === GeminiEventType.Retry && + event.type === LlmEventType.Retry && event.isContinuation === true, ); } - if (event.type === GeminiEventType.Content) { + if (event.type === LlmEventType.Content) { agentOutput.appendText(event.value); - } else if (event.type === GeminiEventType.Finished) { + } else if (event.type === LlmEventType.Finished) { agentOutput.observeFinishReason(event.value?.reason); } - if (messageDisplay && event.type === GeminiEventType.Content) { + if (messageDisplay && event.type === LlmEventType.Content) { messageDisplay.addChunk(event.value); } if (shouldUpdateIdeContextState && !didUpdateIdeContextState) { @@ -3532,7 +3529,7 @@ export class GeminiClient { } const loopType = this.loopDetector.getLastLoopType(); yield { - type: GeminiEventType.LoopDetected, + type: LlmEventType.LoopDetected, ...(loopType && { value: { loopType } }), }; if (arenaAgentClient) { @@ -3564,7 +3561,7 @@ export class GeminiClient { } const loopType = this.loopDetector.getLastLoopType(); yield { - type: GeminiEventType.LoopDetected, + type: LlmEventType.LoopDetected, ...(loopType && { value: { loopType } }), }; if (arenaAgentClient) { @@ -3580,14 +3577,14 @@ export class GeminiClient { } // Update arena status on Finished events — stats are derived // automatically from uiTelemetryService by the reporter. - if (arenaAgentClient && event.type === GeminiEventType.Finished) { + if (arenaAgentClient && event.type === LlmEventType.Finished) { await arenaAgentClient.updateStatus(); } // Re-send a full IDE context blob on the next regular message — auto // compaction inside chat.sendMessageStream may have summarized away // the previous merged IDE context. - if (event.type === GeminiEventType.ChatCompressed) { + if (event.type === LlmEventType.ChatCompressed) { this.forceFullIdeContext = true; // Auto-compaction summarized away the startup prelude. Rebuild it // before the next turn so env/tool/MCP context isn't lost for the @@ -3624,15 +3621,15 @@ export class GeminiClient { yield goalEvent; } if ( - (event.type === GeminiEventType.UserCancelled && signal.aborted) || - event.type === GeminiEventType.Error + (event.type === LlmEventType.UserCancelled && signal.aborted) || + event.type === LlmEventType.Error ) { for (const goalEvent of await finalizeInterruptedGoalTurn()) { yield goalEvent; } } yield event; - if (event.type === GeminiEventType.Error) { + if (event.type === LlmEventType.Error) { this.forceFullIdeContext = true; if (arenaAgentClient) { const status = event.value.error?.status; @@ -3786,7 +3783,7 @@ export class GeminiClient { // This should happen regardless of the hook's decision if (stopOutput?.systemMessage) { yield { - type: GeminiEventType.HookSystemMessage, + type: LlmEventType.HookSystemMessage, value: stopOutput.systemMessage, }; } @@ -3811,7 +3808,7 @@ export class GeminiClient { stopHookBlockingCap, ); yield { - type: GeminiEventType.HookSystemMessage, + type: LlmEventType.HookSystemMessage, value: warning, }; debugLogger.warn(warning); @@ -3825,7 +3822,7 @@ export class GeminiClient { yield goalEvent; } yield { - type: GeminiEventType.StopHookLoop, + type: LlmEventType.StopHookLoop, value: { iterationCount: currentIterationCount, reasons: currentReasons, @@ -3933,7 +3930,7 @@ export class GeminiClient { yield activeGoalEvent; } yield { - type: GeminiEventType.HookSystemMessage, + type: LlmEventType.HookSystemMessage, value: warning, }; debugLogger.warn(warning); @@ -3949,7 +3946,7 @@ export class GeminiClient { } yield { - type: GeminiEventType.StopHookLoop, + type: LlmEventType.StopHookLoop, value: { iterationCount: currentIterationCount, reasons: currentReasons, @@ -4371,7 +4368,7 @@ export class GeminiClient { } /** - * Wrapper around {@link GeminiChat.tryCompress} that restores main-session + * Wrapper around {@link LlmChat.tryCompress} that restores main-session * startup context after successful compaction and flips the IDE full-context * flag for the next regular message. */ @@ -4406,7 +4403,7 @@ export class GeminiClient { previousSessionStartSource, ); } - // startChat() creates a new GeminiChat without touching FileReadCache, + // startChat() creates a new LlmChat without touching FileReadCache, // so prior read_file results that were summarised away would still // resolve to the file_unchanged placeholder. Clear so post-compaction // Reads re-emit bytes the model can no longer see in history. @@ -4480,7 +4477,7 @@ export class GeminiClient { /** * Fast, rule-based compression without any LLM side-query. - * Delegates to {@link GeminiChat.compressFast} and handles post-compression + * Delegates to {@link LlmChat.compressFast} and handles post-compression * FileReadCache disarming. */ async tryCompressChatFast(): Promise { @@ -4501,3 +4498,6 @@ export class GeminiClient { return info; } } + +/** @deprecated Use `LlmClient`; retained until a future major release. */ +export { LlmClient as GeminiClient }; diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 99bd32f2cf8..1bd2191b67e 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -562,10 +562,10 @@ export async function createContentGenerator( authType === AuthType.USE_VERTEX_AI ) { loadBaseGenerator = async () => { - const { createGeminiContentGenerator } = await import( - './geminiContentGenerator/index.js' + const { createLlmContentGenerator } = await import( + './llm-content-generator/index.js' ); - return createGeminiContentGenerator(generatorConfig, config); + return createLlmContentGenerator(generatorConfig, config); }; } else { throw new Error( diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index cfd08de4cca..7839058dd89 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -62,7 +62,7 @@ import { MOCK_TOOL_GET_DEFAULT_PERMISSION, MOCK_TOOL_GET_CONFIRMATION_DETAILS, } from '../test-utils/mock-tool.js'; -import { GeminiChat } from './geminiChat.js'; +import { LlmChat } from './llm-chat.js'; import { MessageBusType } from '../confirmation-bus/types.js'; import type { HookExecutionResponse } from '../confirmation-bus/types.js'; import { type NotificationType } from '../hooks/types.js'; @@ -822,7 +822,7 @@ describe('CoreToolScheduler', () => { onToolCallsUpdate?: ReturnType; memoryMonitor?: { scheduleCheck: () => void }; toolOutputBatchBudget?: number; - getGeminiClient?: () => unknown; + getLlmClient?: () => unknown; getPlanFilePath?: () => string; truncateToolOutputThreshold?: number; truncateToolOutputLines?: number; @@ -900,7 +900,7 @@ describe('CoreToolScheduler', () => { getToolRegistry: () => mockToolRegistry, getCwd: () => '/repo', getUseModelRouter: () => false, - getGeminiClient: options.getGeminiClient ?? (() => null), + getLlmClient: options.getLlmClient ?? (() => null), getPlanFilePath: options.getPlanFilePath ?? (() => '/tmp/plans/test-session-id.md'), getChatRecordingService: () => undefined, @@ -1499,8 +1499,8 @@ describe('CoreToolScheduler', () => { expect(execute).toHaveBeenCalledWith({ plan: 'Original plan' }); }); - function createChatWithPlanCall(callId: string, plan: string): GeminiChat { - return new GeminiChat({} as unknown as Config, {}, [ + function createChatWithPlanCall(callId: string, plan: string): LlmChat { + return new LlmChat({} as unknown as Config, {}, [ { role: 'user', parts: [{ text: 'please plan this' }] }, { role: 'model', @@ -1543,7 +1543,7 @@ describe('CoreToolScheduler', () => { toolsByName: new Map([[ToolNames.EXIT_PLAN_MODE, tool]]), approvalMode: ApprovalMode.YOLO, onAllToolCallsComplete, - getGeminiClient: () => ({ getChat: () => chat }), + getLlmClient: () => ({ getChat: () => chat }), getPlanFilePath: () => planFile, }); @@ -1610,7 +1610,7 @@ describe('CoreToolScheduler', () => { messageBus, disableHooks: false, onAllToolCallsComplete, - getGeminiClient: () => ({ getChat: () => chat }), + getLlmClient: () => ({ getChat: () => chat }), getPlanFilePath: () => planFile, }); @@ -1664,7 +1664,7 @@ describe('CoreToolScheduler', () => { toolsByName: new Map([[ToolNames.EXIT_PLAN_MODE, tool]]), approvalMode: ApprovalMode.YOLO, onAllToolCallsComplete, - getGeminiClient: () => ({ getChat: () => chat }), + getLlmClient: () => ({ getChat: () => chat }), getPlanFilePath: () => planFile, }); @@ -1710,7 +1710,7 @@ describe('CoreToolScheduler', () => { toolsByName: new Map([[ToolNames.EXIT_PLAN_MODE, tool]]), approvalMode: ApprovalMode.YOLO, onAllToolCallsComplete, - getGeminiClient: () => ({ getChat: () => chat }), + getLlmClient: () => ({ getChat: () => chat }), getPlanFilePath: () => path.join(os.tmpdir(), 'qwen-plan-that-does-not-exist.md'), }); @@ -1749,7 +1749,7 @@ describe('CoreToolScheduler', () => { toolsByName: new Map([[ToolNames.EXIT_PLAN_MODE, tool]]), approvalMode: ApprovalMode.YOLO, onAllToolCallsComplete, - getGeminiClient: () => ({ getChat: () => chat }), + getLlmClient: () => ({ getChat: () => chat }), }); await scheduler.schedule( @@ -5897,7 +5897,7 @@ describe('CoreToolScheduler', () => { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, // No client needed for these tests + getLlmClient: () => null, // No client needed for these tests getChatRecordingService: () => undefined, getMessageBus: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), @@ -5980,7 +5980,7 @@ describe('CoreToolScheduler', () => { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getChatRecordingService: () => undefined, getMessageBus: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), @@ -6067,7 +6067,7 @@ describe('CoreToolScheduler', () => { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getChatRecordingService: () => undefined, getMessageBus: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), @@ -6122,7 +6122,7 @@ describe('CoreToolScheduler', () => { const mockConfig = { getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getPermissionsDeny: () => undefined, isInteractive: () => true, getMessageBus: vi.fn().mockReturnValue(undefined), @@ -6305,7 +6305,7 @@ describe('CoreToolScheduler', () => { const mockConfig = { getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, // No client needed for these tests + getLlmClient: () => null, // No client needed for these tests getPermissionsDeny: () => undefined, isInteractive: () => true, getMessageBus: vi.fn().mockReturnValue(undefined), @@ -6349,7 +6349,7 @@ describe('CoreToolScheduler', () => { const mockConfig = { getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getPermissionsDeny: () => ['write_file', 'edit', 'run_shell_command'], isInteractive: () => false, // Value doesn't matter, but included for completeness getMessageBus: vi.fn().mockReturnValue(undefined), @@ -6382,7 +6382,7 @@ describe('CoreToolScheduler', () => { const mockConfig = { getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getPermissionsDeny: () => ['write_file', 'edit'], isInteractive: () => false, // Value doesn't matter getMessageBus: vi.fn().mockReturnValue(undefined), @@ -6427,7 +6427,7 @@ describe('CoreToolScheduler', () => { const mockConfig = { getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getPermissionsDeny: () => undefined, isInteractive: () => true, getMessageBus: vi.fn().mockReturnValue(undefined), @@ -6475,7 +6475,7 @@ describe('CoreToolScheduler', () => { const mockConfig = { getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getPermissionsDeny: () => undefined, isInteractive: () => true, getMessageBus: vi.fn().mockReturnValue(undefined), @@ -6522,7 +6522,7 @@ describe('CoreToolScheduler', () => { const mockConfig = { getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getPermissionsDeny: () => undefined, isInteractive: () => true, getMessageBus: vi.fn().mockReturnValue(undefined), @@ -6569,7 +6569,7 @@ describe('CoreToolScheduler', () => { const mockConfig = { getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getPermissionsDeny: () => undefined, isInteractive: () => true, getMessageBus: vi.fn().mockReturnValue(undefined), @@ -6643,7 +6643,7 @@ describe('CoreToolScheduler', () => { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getChatRecordingService: () => undefined, getMessageBus: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), @@ -6735,7 +6735,7 @@ describe('CoreToolScheduler', () => { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getChatRecordingService: () => undefined, getMessageBus: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), @@ -6827,7 +6827,7 @@ describe('CoreToolScheduler with payload', () => { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, // No client needed for these tests + getLlmClient: () => null, // No client needed for these tests isInteractive: () => true, // Required to prevent auto-denial of tool calls getIdeMode: () => false, getExperimentalZedIntegration: () => false, @@ -7254,7 +7254,7 @@ describe('CoreToolScheduler edit cancellation', () => { }, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, // No client needed for these tests + getLlmClient: () => null, // No client needed for these tests isInteractive: () => true, // Required to prevent auto-denial of tool calls getIdeMode: () => false, getExperimentalZedIntegration: () => false, @@ -7362,7 +7362,7 @@ describe('CoreToolScheduler YOLO mode', () => { getTruncateToolOutputThreshold: () => 100_000, getTruncateToolOutputLines: () => 10_000, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, isInteractive: () => isInteractive, getIdeMode: () => false, getExperimentalZedIntegration: () => false, @@ -7483,7 +7483,7 @@ describe('CoreToolScheduler YOLO mode', () => { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getUseModelRouter: () => false, - getGeminiClient: () => null, // No client needed for these tests + getLlmClient: () => null, // No client needed for these tests getChatRecordingService: () => undefined, getMessageBus: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), @@ -8042,7 +8042,7 @@ describe('CoreToolScheduler request queueing', () => { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, // No client needed for these tests + getLlmClient: () => null, // No client needed for these tests getChatRecordingService: () => undefined, getMessageBus: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), @@ -8168,7 +8168,7 @@ describe('CoreToolScheduler request queueing', () => { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, // No client needed for these tests + getLlmClient: () => null, // No client needed for these tests getChatRecordingService: () => undefined, getMessageBus: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), @@ -8242,7 +8242,7 @@ describe('CoreToolScheduler request queueing', () => { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getUseModelRouter: () => false, - getGeminiClient: () => null, // No client needed for these tests + getLlmClient: () => null, // No client needed for these tests isInteractive: () => true, // Required to prevent auto-denial of tool calls getIdeMode: () => false, getExperimentalZedIntegration: () => false, @@ -8441,7 +8441,7 @@ describe('CoreToolScheduler request queueing', () => { getPermissionManager: () => permissionManager, getAutoModeDenialState: () => denialState, setAutoModeDenialState, - getGeminiClient: () => ({ getHistoryTail: () => [] }), + getLlmClient: () => ({ getHistoryTail: () => [] }), getToolRegistry: () => toolRegistry, getAutoModeSettings: () => ({}), getModel: () => 'test-model', @@ -8724,7 +8724,7 @@ describe('CoreToolScheduler truncated output protection', () => { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getChatRecordingService: () => undefined, isInteractive: () => true, getMessageBus: vi.fn().mockReturnValue(undefined), @@ -9032,7 +9032,7 @@ describe('CoreToolScheduler Sequential Execution', () => { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getChatRecordingService: () => undefined, getMessageBus: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), @@ -9155,7 +9155,7 @@ describe('CoreToolScheduler Sequential Execution', () => { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getChatRecordingService: () => undefined, getMessageBus: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), @@ -9345,7 +9345,7 @@ describe('CoreToolScheduler plan mode with ask_user_question', () => { getConditionalRulesRegistry: () => undefined, getSkillManager: () => undefined, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, isInteractive: () => true, getIdeMode: () => false, getExperimentalZedIntegration: () => false, @@ -10172,7 +10172,7 @@ describe('CoreToolScheduler Plan shell routing', () => { getConditionalRulesRegistry: () => undefined, getSkillManager: () => undefined, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, isInteractive: () => options.interactive ?? true, getIdeMode: () => options.ideMode ?? false, getExperimentalZedIntegration: () => false, @@ -11010,7 +11010,7 @@ describe('CoreToolScheduler telemetry spans', () => { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getChatRecordingService: () => undefined, getMessageBus: vi.fn().mockReturnValue(options.messageBus), getDisableAllHooks: vi.fn().mockReturnValue(options.disableHooks ?? true), @@ -13475,7 +13475,7 @@ describe('CoreToolScheduler telemetry spans', () => { storage: { getProjectTempDir: () => '/tmp' }, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, isInteractive: () => true, getIdeMode: () => false, getExperimentalZedIntegration: () => false, @@ -13960,7 +13960,7 @@ describe('CoreToolScheduler telemetry spans', () => { storage: { getProjectTempDir: () => '/tmp' }, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, isInteractive: () => true, getIdeMode: overrides.getIdeMode ?? (() => false), getExperimentalZedIntegration: () => false, @@ -14066,7 +14066,7 @@ describe('CoreToolScheduler telemetry spans', () => { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getChatRecordingService: () => undefined, isInteractive: () => true, getIdeMode: () => false, @@ -14343,7 +14343,7 @@ describe('CoreToolScheduler telemetry spans', () => { storage: { getProjectTempDir: () => '/tmp' }, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, isInteractive: () => true, getIdeMode: () => false, getExperimentalZedIntegration: () => false, @@ -14414,7 +14414,7 @@ describe('CoreToolScheduler telemetry spans', () => { storage: { getProjectTempDir: () => '/tmp' }, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, isInteractive: () => false, // forces non-interactive deny path getInputFormat: () => undefined, getIdeMode: () => false, @@ -14500,7 +14500,7 @@ describe('CoreToolScheduler telemetry spans', () => { storage: { getProjectTempDir: () => '/tmp' }, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, isInteractive: () => true, getIdeMode: () => false, getExperimentalZedIntegration: () => false, @@ -14573,7 +14573,7 @@ describe('CoreToolScheduler telemetry spans', () => { storage: { getProjectTempDir: () => '/tmp' }, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, isInteractive: () => true, getIdeMode: () => false, getExperimentalZedIntegration: () => false, @@ -14704,7 +14704,7 @@ describe('CoreToolScheduler telemetry spans', () => { storage: { getProjectTempDir: () => '/tmp' }, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, isInteractive: () => true, getIdeMode: () => false, getExperimentalZedIntegration: () => false, @@ -14814,7 +14814,7 @@ describe('CoreToolScheduler telemetry spans', () => { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getChatRecordingService: () => undefined, getMessageBus: vi.fn(() => { throw new Error('prelude boom — getMessageBus throws'); @@ -14977,7 +14977,7 @@ describe('CoreToolScheduler telemetry spans', () => { storage: { getProjectTempDir: () => '/tmp' }, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, isInteractive: () => true, getIdeMode: () => false, getExperimentalZedIntegration: () => false, @@ -15213,7 +15213,7 @@ describe('CoreToolScheduler telemetry spans', () => { storage: { getProjectTempDir: () => '/tmp' }, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, isInteractive: () => true, getIdeMode: () => false, getExperimentalZedIntegration: () => false, @@ -15862,7 +15862,7 @@ describe('Fire hook functions integration', () => { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getChatRecordingService: () => undefined, getMessageBus: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), @@ -16538,7 +16538,7 @@ describe('CoreToolScheduler IDE interaction', () => { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, isInteractive: () => true, getIdeMode: () => overrides.ideMode ?? true, getExperimentalZedIntegration: () => false, @@ -16971,7 +16971,7 @@ describe('CoreToolScheduler validation retry loop detection', () => { getTruncateToolOutputLines: () => 10, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, isInteractive: () => true, getIdeMode: () => false, getExperimentalZedIntegration: () => false, @@ -17315,7 +17315,7 @@ describe('CoreToolScheduler validation retry loop detection', () => { getTruncateToolOutputLines: () => 10, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, isInteractive: () => true, getIdeMode: () => false, getExperimentalZedIntegration: () => false, @@ -17737,7 +17737,7 @@ describe('CoreToolScheduler activation wiring', () => { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getChatRecordingService: () => undefined, getMessageBus: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), @@ -18032,7 +18032,7 @@ describe('CoreToolScheduler activation wiring', () => { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getChatRecordingService: () => undefined, getMessageBus: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), @@ -18149,7 +18149,7 @@ describe('CoreToolScheduler activation wiring', () => { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getChatRecordingService: () => undefined, getMessageBus: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), @@ -18269,7 +18269,7 @@ describe('CoreToolScheduler activation wiring', () => { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getChatRecordingService: () => undefined, getMessageBus: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), @@ -18364,7 +18364,7 @@ describe('CoreToolScheduler activation wiring', () => { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getChatRecordingService: () => undefined, getMessageBus: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), @@ -18460,7 +18460,7 @@ describe('CoreToolScheduler activation wiring', () => { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, getChatRecordingService: () => undefined, getMessageBus: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), @@ -18842,7 +18842,7 @@ describe('CoreToolScheduler prompt_id propagation', () => { }, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, isInteractive: () => true, getIdeMode: () => false, getExperimentalZedIntegration: () => false, @@ -18924,7 +18924,7 @@ describe('CoreToolScheduler prompt_id propagation', () => { }, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, isInteractive: () => true, getIdeMode: () => false, getExperimentalZedIntegration: () => false, @@ -18997,7 +18997,7 @@ describe('CoreToolScheduler prompt_id propagation', () => { }, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, isInteractive: () => true, getIdeMode: () => false, getExperimentalZedIntegration: () => false, @@ -19071,7 +19071,7 @@ describe('CoreToolScheduler prompt_id propagation', () => { }, getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, - getGeminiClient: () => null, + getLlmClient: () => null, isInteractive: () => true, getIdeMode: () => false, getExperimentalZedIntegration: () => false, diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 4f79123a781..ed9940d407d 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -65,7 +65,7 @@ import { isDeepStrictEqual } from 'node:util'; import { ToolNames, canonicalToolName } from '../tools/tool-names.js'; import { resolveToolName } from '../permissions/rule-parser.js'; import { PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES } from '../tools/exitPlanMode.js'; -import { approvedPlanRedactionText } from './geminiChat.js'; +import { approvedPlanRedactionText } from './llm-chat.js'; import * as fsSync from 'node:fs'; import { collectAvailableSkillEntries, @@ -2988,7 +2988,7 @@ export class CoreToolScheduler { // fast-path AUTO call. const messages = this.config - .getGeminiClient?.() + .getLlmClient?.() ?.getHistoryTail(MAX_TRANSCRIPT_MESSAGES, false) ?? []; const decision = await runInRequestGoalContext(reqInfo, () => evaluateAutoMode({ @@ -5513,7 +5513,7 @@ export class CoreToolScheduler { // throws here and skips the redaction entirely. const savedPlan = fsSync.readFileSync(planPath, 'utf-8'); const redacted = this.config - .getGeminiClient?.() + .getLlmClient?.() ?.getChat() .redactApprovedPlanFromHistory( callId, @@ -6360,7 +6360,7 @@ export class CoreToolScheduler { const fallback = shouldFallback(denialState); const messages = this.config - .getGeminiClient?.() + .getLlmClient?.() ?.getHistoryTail(MAX_TRANSCRIPT_MESSAGES, false) ?? []; const decision = await runInRequestGoalContext( pendingTool.request, diff --git a/packages/core/src/core/environmentContext.mcp-subagent.test.ts b/packages/core/src/core/environmentContext.mcp-subagent.test.ts index 7a82f09c456..56c236f3b71 100644 --- a/packages/core/src/core/environmentContext.mcp-subagent.test.ts +++ b/packages/core/src/core/environmentContext.mcp-subagent.test.ts @@ -59,7 +59,7 @@ describe('MCP server instructions and subagent registries', () => { it('leaves a skipDiscovery registry with no server instructions', async () => { const config = makeConfig({ 'server-a': { command: 'a' } }); await config.initialize({ - skipGeminiInitialization: true, + skipLlmInitialization: true, skipHooks: true, skipMcpDiscovery: true, skipFileCheckpointing: true, @@ -89,7 +89,7 @@ describe('MCP server instructions and subagent registries', () => { // instead of the provisioned worktree. const config = makeConfig({ 'server-a': { command: 'a' } }); await config.initialize({ - skipGeminiInitialization: true, + skipLlmInitialization: true, skipHooks: true, skipMcpDiscovery: true, skipFileCheckpointing: true, @@ -117,7 +117,7 @@ describe('MCP server instructions and subagent registries', () => { // propagated instructions inside the rebuild would survive. const config = makeConfig({ 'server-a': { command: 'a' } }); await config.initialize({ - skipGeminiInitialization: true, + skipLlmInitialization: true, skipHooks: true, skipMcpDiscovery: true, skipFileCheckpointing: true, diff --git a/packages/core/src/core/environmentContext.ts b/packages/core/src/core/environmentContext.ts index 146820d2e03..1f12ff7d55e 100644 --- a/packages/core/src/core/environmentContext.ts +++ b/packages/core/src/core/environmentContext.ts @@ -643,11 +643,11 @@ function isModelFunctionCallEntry(content: Content | undefined): boolean { * * These are structural history entries — the startup-context prelude * (history[0]) and the mid-history MCP added-tool reminders injected by - * `GeminiClient.drainPendingAddedMcpToolsReminder` — NOT real user turns. + * `LlmClient.drainPendingAddedMcpToolsReminder` — NOT real user turns. * * The "every part" requirement is load-bearing. Per-turn reminders (plan * mode, subagent list, recalled memory) are prepended as an extra part to the - * SAME user `Content` as the actual prompt: `GeminiClient.sendMessageStream` + * SAME user `Content` as the actual prompt: `LlmClient.sendMessageStream` * assembles `[...systemReminders, ...userPrompt]` into one `createUserContent` * that persists in history. Such a turn has a non-reminder prompt part, so it * is NOT pure — matching on `parts[0]` alone would misclassify a genuine user diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index fe5d04404cd..36f37531410 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -4,5716 +4,5 @@ * SPDX-License-Identifier: Apache-2.0 */ -// DISCLAIMER: This is a copied version of https://github.com/googleapis/js-genai/blob/main/src/chats.ts with the intention of working around a key bug -// where function responses are not treated as "valid" responses: https://b.corp.google.com/issues/420354090 - -import type { - GenerateContentResponse, - Content, - GenerateContentConfig, - FunctionCall, - SendMessageParameters, - Part, - Tool, - GenerateContentResponseUsageMetadata, -} from '@google/genai'; -import { createUserContent, FinishReason } from './genai-compat.js'; -import { enforceFunctionResponseBudget } from '../tools/tool-response-finalizer.js'; -import { - retryWithBackoff, - isUnattendedMode, - type HeartbeatInfo, -} from '../utils/retry.js'; -import { - isQuotaExhaustedError, - formatQuotaExhaustedMessage, -} from '../utils/quotaErrorDetection.js'; -import { getErrorStatus, isAbortError } from '../utils/errors.js'; -import { createDebugLogger } from '../utils/debugLogger.js'; -import { - containsXmlToolCalls, - tryRecoverXmlToolCalls, -} from './xml-tool-call-fallback.js'; -import { parseAndFormatApiError } from '../utils/errorParsing.js'; -import { - getRateLimitErrorDetails, - getRateLimitRetryDelayMs, - isRateLimitError, - type RetryInfo, -} from '../utils/rateLimit.js'; -import { - classifyRetryError, - isFallbackEligible, -} from '../utils/retryErrorClassification.js'; -import type { Config } from '../config/config.js'; -import type { ContentGenerator, InputModalities } from './contentGenerator.js'; -import { - clampOutputTokensToWindow, - defaultOutputCeiling, - DEFAULT_TOKEN_LIMIT, - OUTPUT_TOKEN_CEILING, - parsePositiveIntegerEnvValue, -} from './tokenLimits.js'; -import { hasCycleInSchema } from '../tools/tools.js'; -import { ToolNames, canonicalToolName } from '../tools/tool-names.js'; -import * as fs from 'node:fs'; -import { PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES } from '../tools/exitPlanMode.js'; -import { isManagedMemoryPath } from '../memory/paths.js'; -import { STRUCTURED_OUTPUT_REDACTED_ARGS } from '../tools/syntheticOutput.js'; -import type { StructuredError } from './turn.js'; -import { - logContentRetry, - logContentRetryFailure, - logApiRetry, - logChatCompression, -} from '../telemetry/loggers.js'; -import { subagentNameContext } from '../utils/subagentNameContext.js'; -import { type ChatRecordingService } from '../services/chatRecordingService.js'; -import { - ChatCompressionService, - computeThresholds, - MAX_CONSECUTIVE_FAILURES, - type CompactTrigger, -} from '../services/chatCompressionService.js'; -import { acquireSleepInhibitor } from '../services/sleepInhibitor.js'; -import { - getFunctionResponseParts, - resolveCompactionTuning, - resolveSlimmingConfig, - slimCompactionInput, -} from '../services/compactionInputSlimming.js'; -import { - InMemoryImagePayloadStore, - buildReattachParts, - countAllInlineImages, - replaceImagePayloadsInPlace, -} from '../services/image-payload-references.js'; -import { - estimateContentTokens, - estimatePromptTokens, - getUsageOutputTokenCountForPromptEstimate, -} from '../services/tokenEstimation.js'; -import { - microcompactHistory, - type MicrocompactMeta, -} from '../services/microcompaction/microcompact.js'; -import { - ContentRetryEvent, - ContentRetryFailureEvent, - ApiRetryEvent, - makeChatCompressionEvent, -} from '../telemetry/types.js'; -import type { UiTelemetryService } from '../telemetry/uiTelemetry.js'; -import { type ChatCompressionInfo, CompressionStatus } from './turn.js'; -import { getContextLengthExceededInfo } from '../utils/contextLengthError.js'; -import { - getStartupContextLength, - isSystemReminderContent, -} from './environmentContext.js'; -import type { SessionStartSource } from '../hooks/types.js'; -import { - getCustomSystemPrompt, - getManualPlanExitSystemReminder, -} from './prompts.js'; -import { isRetryableStreamTransportError } from './stream-transport-retry.js'; -import { - collectToolCallIdsFromHistory, - getFunctionCallFingerprint, - normalizeModelToolCallIds, - reserveModelToolCallId, -} from './toolCallIdUtils.js'; -import { - getToolCallPreparations, - setToolCallPreparations, -} from './tool-call-preparation.js'; -import { InvalidStreamError } from './invalid-stream-error.js'; -import type { GoalTurnPermit } from '../goals/goal-protocol.js'; - -export { InvalidStreamError }; - -const debugLogger = createDebugLogger('QWEN_CODE_CHAT'); -// Gemini can emit this filler after tool results; filtering and validation -// must stay in sync. -const GEMINI_EMPTY_CONTENT_PLACEHOLDER = '(empty content)'; - -function hasCandidateOutput(response: GenerateContentResponse): boolean { - return Boolean( - response.candidates?.some( - (candidate) => - Boolean(candidate.finishReason) || - (candidate.content?.parts?.length ?? 0) > 0, - ), - ); -} - -/** - * True when the chunk carries model output beyond ephemeral reasoning: - * any candidate part without the `thought` flag (text, functionCall, - * inlineData, …). Thought parts stream reasoning that is never recorded - * as the assistant's final response in history, so replaying a request - * that has produced only thought parts cannot duplicate user-visible - * output — the distinction the transport stream retry gate relies on - * (#7832). - */ -function hasNonThoughtCandidateParts( - response: GenerateContentResponse, -): boolean { - return Boolean( - response.candidates?.some((candidate) => - candidate.content?.parts?.some((part) => !part.thought), - ), - ); -} - -function syncFunctionCallsField( - response: GenerateContentResponse, - parts: readonly Part[], -): void { - const functionCalls = parts - .map((part) => part.functionCall) - .filter((call): call is FunctionCall => Boolean(call)); - const value = functionCalls.length > 0 ? functionCalls : undefined; - - let owner: object | null = response; - let descriptor: PropertyDescriptor | undefined; - while (owner && !descriptor) { - descriptor = Object.getOwnPropertyDescriptor(owner, 'functionCalls'); - owner = Object.getPrototypeOf(owner); - } - - if (descriptor?.set) { - ( - response as GenerateContentResponse & { functionCalls?: FunctionCall[] } - ).functionCalls = value; - return; - } - - if (!descriptor || descriptor.writable || descriptor.get) { - Object.defineProperty(response, 'functionCalls', { - value, - writable: true, - configurable: true, - enumerable: true, - }); - } -} - -/** - * Resolves legacy tool-name aliases via the shared `canonicalToolName` so - * the load-side plan redaction keeps matching sessions recorded under a - * pre-migration name, in lockstep with the write-side scheduler. - */ -function canonicalPlanToolName(toolName: string | undefined): string { - if (!toolName) return ''; - return canonicalToolName(toolName); -} - -/** - * Single source of the pointer text that replaces an approved plan's - * `functionCall.args.plan` (#6237). Shared by the tool scheduler's - * post-approval rewrite and the load-side pass below so the two surfaces - * cannot drift. - */ -export function approvedPlanRedactionText(planPath: string): string { - return ( - `[Plan approved and saved to ${planPath}. The plan text was ` + - `removed from the conversation after approval; read that ` + - `file if you need to consult it again.]` - ); -} - -/** - * Pure history-wide variant of the approved-plan redaction: rewrites the - * `plan` argument of every `exit_plan_mode` `functionCall` whose paired - * `functionResponse` carries an approval `llmContent` AND whose plan text - * equals `savedPlanContent` (the current on-disk plan file). Returns a new - * array when anything changed, or null when the history is untouched. - * - * Exported for tests; production callers go through - * `GeminiChat.setHistory` / the constructor. - */ -export function redactApprovedPlansInHistory( - history: Content[], - savedPlanContent: string, - planPath: string, -): Content[] | null { - const approved = new Set(); - for (const entry of history) { - if (!entry?.parts) continue; - for (const part of entry.parts) { - const fr = part.functionResponse; - if ( - !fr?.id || - canonicalPlanToolName(fr.name) !== ToolNames.EXIT_PLAN_MODE - ) - continue; - const output = (fr.response as { output?: unknown } | undefined)?.[ - 'output' - ]; - if ( - typeof output === 'string' && - PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES.some((prefix) => - output.startsWith(prefix), - ) - ) { - approved.add(fr.id); - } - } - } - if (approved.size === 0) return null; - - let changed = false; - const out = history.map((entry) => { - if (entry?.role !== 'model' || !entry.parts) return entry; - let entryChanged = false; - const parts = entry.parts.map((part) => { - const fc = part.functionCall; - if ( - !fc?.id || - canonicalPlanToolName(fc.name) !== ToolNames.EXIT_PLAN_MODE - ) - return part; - if (!approved.has(fc.id)) return part; - if ((fc.args ?? {})['plan'] !== savedPlanContent) return part; - entryChanged = true; - return { - ...part, - functionCall: { - ...fc, - args: { ...fc.args, plan: approvedPlanRedactionText(planPath) }, - }, - }; - }); - if (!entryChanged) return entry; - changed = true; - return { ...entry, parts }; - }); - return changed ? out : null; -} - -/** - * Replaces the args on a `structured_output` `functionCall` with the - * same `__redacted` placeholder used by `ToolCallEvent` telemetry - * (`packages/core/src/telemetry/types.ts`). - * - * The chat-recording JSONL (`/chats/.jsonl`) - * persists assistant turns to disk and re-feeds them on - * `--continue` / `--resume`. For `--json-schema` runs the tool args - * ARE the user's structured payload — already emitted on stdout via - * `result` / `structured_result`. Recording them verbatim here would - * mean the same payload (and every validation-failure retry along the - * way) sits on disk indefinitely, contradicting the privacy contract - * documented next to the telemetry redaction. Mirror the placeholder - * here so the chat-recording surface matches. - * - * Non-`structured_output` `functionCall`s pass through untouched. - * - * Exported for tests; callers should prefer the inline use inside - * `recordAssistantTurn` invocation below. - */ -export function redactStructuredOutputArgsForRecording( - part: Part, -): { functionCall: NonNullable } | null { - if (!part.functionCall) return null; - if (part.functionCall.name !== ToolNames.STRUCTURED_OUTPUT) { - return { functionCall: part.functionCall }; - } - return { - functionCall: { - ...part.functionCall, - args: { ...STRUCTURED_OUTPUT_REDACTED_ARGS }, - }, - }; -} - -function isCompressionFailureStatus(status: CompressionStatus): boolean { - return ( - status === CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT || - status === CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY || - status === CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR || - status === CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED - ); -} - -function shouldStopAfterHardRescue( - shouldForceFromHard: boolean, - hardLimit: number, - localPromptTokensAfterCompression: number, -): boolean { - return shouldForceFromHard && localPromptTokensAfterCompression >= hardLimit; -} - -function getHardRescueFailureMessage( - effectiveTokens: number, - hardLimit: number, - compressionInfo: ChatCompressionInfo, - localPromptTokensAfterCompression: number, -): string { - const compressionStatus = - CompressionStatus[compressionInfo.compressionStatus] ?? - String(compressionInfo.compressionStatus); - const tokenCount = - compressionInfo.compressionStatus === CompressionStatus.COMPRESSED - ? Math.max( - compressionInfo.newTokenCount, - localPromptTokensAfterCompression, - ) - : Math.max(effectiveTokens, localPromptTokensAfterCompression); - return ( - `Context is too large to send safely after automatic compression. ` + - `Estimated prompt tokens: ${tokenCount}; hard limit: ${hardLimit}; ` + - `compression status: ${compressionStatus}. ` + - `Start a new session or reduce the resumed history before continuing.` - ); -} - -/** - * Defensive coercion for API-reported token counts. - * - * Hostile providers (broken upstream, OpenAI-compat proxy returning - * `null`/`NaN`, misconfigured override) can yield non-finite or negative - * token counts on `usageMetadata`. This function coerces the four fields that - * feed the compaction gate, its cache-hit telemetry, or OTel spans — - * `promptTokenCount`, `totalTokenCount`, `candidatesTokenCount`, and - * `cachedContentTokenCount`. Letting hostile values - * flow into the compaction gate arithmetic is catastrophic: - * - * - `lastPromptTokenCount + NaN >= hard` is always false → hard-rescue is - * silently disabled, eventually OOMing the V8 heap. - * - `Infinity >= hard` is always true → hard-rescue fires on every send. - * - * Coercing unknown / negative / non-finite to `0` keeps the gate well-defined - * and is a no-op for any provider returning sane values. - * - * `Number.isFinite(-1)` is `true`, so the explicit `>= 0` check is required - * in addition to `isFinite`. - */ -function coerceUsageCount(value: unknown, field?: string): number { - if (typeof value === 'number' && Number.isFinite(value) && value >= 0) { - return value; - } - if (value != null && field) { - debugLogger.warn( - `coerceUsageCount: hostile ${field}=${String(value)}, coercing to 0`, - ); - } - return 0; -} - -export enum StreamEventType { - /** A regular content chunk from the API. */ - CHUNK = 'chunk', - /** A signal that a retry is about to happen. The UI should discard any partial - * content from the attempt that just failed. */ - RETRY = 'retry', - /** Emitted once at the start of the stream when an automatic compression - * pass succeeded. Carries the compression result so callers (the main - * agent UI, subagent loop) can surface it without each call site running - * its own compaction step. */ - COMPRESSED = 'compressed', - /** Emitted when the primary model (or a prior fallback) exhausted its retry - * budget on a capacity/availability error and the system is switching to the - * next fallback model. The UI should discard partial content and display a - * notification about the model switch. */ - MODEL_FALLBACK = 'model_fallback', -} - -/** Information about a model fallback transition. */ -export interface ModelFallbackInfo { - /** The model that exhausted its retry budget. */ - fromModel: string; - /** The model the system is switching to. */ - toModel: string; - /** HTTP status code that triggered the fallback (e.g. 429, 503, 529). */ - statusCode?: number; - /** 1-based index of the fallback in the configured fallback chain. */ - fallbackIndex: number; -} - -export type StreamEvent = - | { type: StreamEventType.CHUNK; value: GenerateContentResponse } - | { - type: StreamEventType.RETRY; - retryInfo?: RetryInfo; - /** When true, the retry is a continuation (recovery) rather than a - * fresh restart (escalation). The UI should keep the accumulated text - * buffer so the continuation appends to it. */ - isContinuation?: boolean; - /** Set when the retry raised the automatic max output token limit. */ - maxOutputTokensEscalated?: number; - } - | { type: StreamEventType.COMPRESSED; info: ChatCompressionInfo } - | { type: StreamEventType.MODEL_FALLBACK; info: ModelFallbackInfo }; - -export interface LlmChatSendOptions { - /** Skip only the configured model fallback chain for this request. */ - disableModelFallbacks?: boolean; -} - -/** @deprecated Use `LlmChatSendOptions`; retained until a future major release. */ -export type GeminiChatSendOptions = LlmChatSendOptions; - -interface TryCompressOptions { - originalTokenCountOverride?: number; - trigger?: CompactTrigger; - /** - * Pending user message about to be sent. Threaded through to the - * compression service's cheap-gate so it can see the real prompt size - * even when `lastPromptTokenCount === 0` (first send after inherited - * history). See `estimatePromptTokens` for the fallback math. - */ - pendingUserMessage?: Content; - /** - * Pre-computed all-inclusive effective prompt count from the caller. When - * set, the cheap-gate uses this instead of recomputing — avoids a second - * `getHistory(true)` clone per send and prevents provider-reported overflow - * counts from double-counting the previous model output. - */ - precomputedEffectiveTokens?: number; - /** Per-request overrides needed to preserve the main request cache prefix. */ - requestGenerationConfig?: GenerateContentConfig; - /** - * Route the enclosing send targets. The entry adoption compares against - * this instead of the active route, so an in-send compression never - * re-adopts counts the active route retained while the request targets - * another one (#9506). Omitted by between-sends callers (manual - * `/compress`), which compress the active route's state. - */ - requestRouteKey?: string; - /** - * Delay writing the compression checkpoint until the caller has run any - * post-compression guards that may roll the in-memory chat state back. - */ - deferChatCompressionRecord?: boolean; - /** - * Forwarded to the compression side-query system prompt. Sourced from - * `/compress ` invocation arg; appended after the base prompt as - * an `Additional Instructions:` block so the summary model can focus - * on the user's stated concern. - */ - customInstructions?: string; -} - -// Model-output validation errors (protocol tag leaks, malformed tool calls) -// and transient stream anomalies (empty streams, no usable text, missing -// finish reason) use an independent retry budget so they do not consume each -// other's or HTTP retries' budgets. -const INVALID_STREAM_RETRY_CONFIG = { - transientMaxRetries: 4, - protocolTagLeakMaxRetries: 2, - initialDelayMs: 2000, -}; - -const TRANSPORT_STREAM_RETRY_CONFIG = { - maxRetries: 2, - initialDelayMs: 1000, - /** - * Budget for *continuation* recovery after a socket-level cut that already - * delivered output (issue #7832). This is a different mechanism from the - * `maxRetries` replay above and therefore has its own budget: a replay - * re-sends the request from scratch and is only legal before any chunk - * reached callers, while a continuation keeps the delivered output and asks - * the model to resume from it. A single long generation can be cut more than - * once by the same gateway idle timeout, so this is sized like - * {@link MAX_OUTPUT_RECOVERY_ATTEMPTS} rather than like the replay budget. - */ - maxContinuationRetries: 3, -}; - -/** - * Pad added when sizing the output clamp from an estimate-derived prompt - * count. This includes a fresh session (`lastPromptTokenCount === 0`) and - * counts propagated through compression or resume before provider usage is - * available. A history-derived count can miss the system prompt, tool - * definitions, and skill content — estimatePromptTokens documents this as - * "typically ~15-20K of under-estimate" — so pad conservatively until - * provider usage arrives. Counts derived from an API baseline may already - * preserve some non-visible overhead; double-counting it is accepted because - * the error direction is safe and provider usage self-corrects it. An - * under-counted prompt is the one way `prompt + max_tokens` can overflow the - * window (issue #5950). Sized to the documented worst case; costs nothing on - * large windows (the output ceiling binds long before the pad matters). - */ -const ESTIMATE_CLAMP_OVERHEAD_PAD = 20_000; - -/** - * Cap on how many routes' token counts are retained while their route is - * not the one owning the chat's count slots (#9506). Route identities are - * bounded by the session's model routes, so this only guards pathological - * selector churn; eviction is FIFO. - */ -const MAX_RETAINED_ROUTE_COUNTS = 8; - -/** - * Max recovery attempts when the escalated response is also truncated. - * Each attempt keeps the partial response in history and injects a recovery - * message so the model can continue from where it left off. - */ -const MAX_OUTPUT_RECOVERY_ATTEMPTS = 3; - -/** - * The resume instruction shared by every recovery user-turn, whatever cut the - * response short. Only the lead-in sentence naming the cause differs between - * the paths below, so the instruction itself lives here: tuning it (say, to - * curb recap behaviour) has to apply to both, and duplicating it invites one - * path to be updated while the other silently keeps the old wording. - */ -const RECOVERY_RESUME_INSTRUCTION = - 'Resume directly — no apology, no recap of what you were doing. Pick up ' + - 'mid-thought if that is where the cut happened. Break remaining work into ' + - 'smaller pieces.'; - -/** - * Recovery message injected as a user turn when the model's output is - * truncated even after token escalation. Instructs the model to resume - * without repeating itself and to break remaining work into smaller steps. - */ -const OUTPUT_RECOVERY_MESSAGE = `Output token limit hit. ${RECOVERY_RESUME_INSTRUCTION}`; - -/** - * Lead-in for the same recovery user-turn when the cause was a socket-level - * cut mid-stream rather than the output token limit (issue #7832). Gateways - * that cap SSE connection lifetime close long generations after a few - * minutes; the response so far is already on the caller's screen, so the only - * safe recovery is to resume from it. Deliberately shares - * {@link RECOVERY_RESUME_INSTRUCTION} with {@link OUTPUT_RECOVERY_MESSAGE} — - * the model does not need to know which limit it hit, only that it was cut - * off and must not restart. - */ -const TRANSPORT_CONTINUATION_MESSAGE = `The connection dropped mid-response. ${RECOVERY_RESUME_INSTRUCTION}`; - -/** - * Maximum length of the previous-response tail embedded inside the - * `` block of the recovery user-turn. Chosen as a - * pragmatic balance: large enough to give the model enough trailing context to - * resume coherently (covers ~200–400 tokens of prose, or a multi-row Markdown - * table), and small enough to keep the recovery prompt well under any - * provider's input budget even when combined with the rest of history. - */ -const OUTPUT_RECOVERY_TAIL_CHARS = 1200; - -/** - * Hard cap on the inner overlap/contained-prefix scan loops. Bounds both the - * suffix-anchored overlap search in {@link getRecoveryContinuationSuffix} and - * the contained-prefix scan in {@link findContainedRecoveryPrefixReplayLength} - * so recovery dedup stays O(min(previous, continuation, 4000)) in iteration - * count instead of unbounded against pathologically large continuations. - */ -const RECOVERY_OVERLAP_MAX_SCAN_CHARS = 4000; - -/** - * Minimum byte-length before a plain-text overlap (between previous tail and - * continuation prefix) is considered "significant" enough to dedup. Short - * coincidental matches like `". "`, `"the "`, or `", and "` happen routinely - * across unrelated turns; requiring ≥6 bytes makes accidental matches on - * common short suffixes vanishingly unlikely while still catching meaningful - * replayed phrases. - */ -const RECOVERY_OVERLAP_MIN_BYTES = 6; - -/** - * Companion floor in *code points* for prose overlaps. The byte floor alone is - * too permissive for CJK: a single Chinese character is 3 UTF-8 bytes, so - * `RECOVERY_OVERLAP_MIN_BYTES = 6` would accept a coincidental 2-character - * overlap like `"我们"` / `"但是"` that is extremely common across unrelated - * Chinese turns. Requiring at least 4 code points in addition to the byte - * floor makes CJK collisions need a 4-character coincidence (~10⁻⁵ when - * each character is independent), without raising the bar for ASCII (4 ASCII - * chars is only 4 bytes — still gated by the 6-byte floor, so ASCII effectively - * needs ≥6 chars). Structural anchors (`#|`\n) are exempted because the - * structural floor already governs them and structural collisions are far - * rarer than prose. - */ -const RECOVERY_OVERLAP_MIN_CHARS = 4; - -/** - * Lower floor for overlaps that contain Markdown structural characters - * (`#`, `|`, backtick, newline). Structural anchors are far less likely to - * collide coincidentally than prose — a 4-byte overlap like `"| a "` or - * `"## "` is almost certainly a replayed block-level marker, so we accept a - * smaller match to catch table/heading replays that the 6-byte prose floor - * would otherwise miss. - */ -const RECOVERY_STRUCTURAL_OVERLAP_MIN_BYTES = 4; -// Plain-prose substring matches outside the suffix-anchored path are very -// prone to false positives on common opener phrases ("In summary, …", "Here is -// the …"). The contained-prefix replay path is reserved for replayed Markdown -// blocks (tables, headings, fenced code), so we require both a structural -// anchor at the start of the prefix and a substantially larger byte floor than -// the suffix path uses. This intentionally errs on the side of leaving rare -// duplicates in history rather than silently dropping legitimate continuation. -const RECOVERY_CONTAINED_PREFIX_MIN_BYTES = 12; -// Limit the substring search to the immediate truncation tail so a coincidental -// match thousands of characters earlier in the previous turn cannot win. -const RECOVERY_CONTAINED_TAIL_LOOKBACK_CHARS = 400; - -function byteLength(text: string): number { - return Buffer.byteLength(text, 'utf8'); -} - -function isSignificantRecoveryOverlap(overlap: string): boolean { - const overlapBytes = byteLength(overlap); - // This is intentionally a loose "contains any of these chars" check rather - // than a strict Markdown-block-anchor parse: an overlap that picks up `#`, - // `` ` ``, `|`, or `\n` is *probably* a replayed structural marker, and - // the 4-byte structural floor only differs from the 6-byte prose floor by - // a 2-byte window. The worst realistic over-classification (4–5 byte prose - // fragments like `"C#dev"` or `"a|b|c"` slipping through the structural - // path instead of the prose path) still requires that fragment to be - // identical at the truncation boundary on both sides, which is far rarer - // than the structural-replay scenarios this lower floor exists to catch. - const hasMarkdownStructure = /[#|`\n]/.test(overlap); - if ( - hasMarkdownStructure && - overlapBytes >= RECOVERY_STRUCTURAL_OVERLAP_MIN_BYTES - ) { - return true; - } - // Prose overlaps must clear *both* the byte floor (covers ASCII) and the - // code-point floor (covers CJK). Counting code points via the spread - // iterator handles surrogate pairs correctly so emoji do not double-count. - const overlapChars = [...overlap].length; - return ( - overlapBytes >= RECOVERY_OVERLAP_MIN_BYTES && - overlapChars >= RECOVERY_OVERLAP_MIN_CHARS - ); -} - -/** - * Returns true if `text` opens with a Markdown block-level structural marker - * (table row, fenced code, ATX heading, blockquote, list item). Leading - * whitespace/newline chars are skipped because providers often prepend them - * when restarting a block — some completion APIs re-emit the suffix with - * leading spaces or tabs, not just newlines. The marker must appear at the - * start of a line and be followed by the syntactic gap the spec requires - * (e.g. `# ` not `#abc`), so incidental `#` or `|` characters in prose do - * not count. - * - * The table-row alternation requires either ≥3 pipes (GFM tables need at - * least 2 cells, i.e. 3 separator pipes) *or* a separator row (`|---|`, - * `|:---:|`, etc.). A bare `|expression|` in technical prose has only 2 - * pipes and no separator syntax, so it is intentionally rejected — that - * pattern is not a valid GFM table row anyway. - */ -function startsWithMarkdownStructuralAnchor(text: string): boolean { - const trimmed = text.replace(/^\s+/, ''); - return /^(\|[^\n]*\|[^\n]*\||\|[\s\-:]+\||#{1,6} |```|>\s|[-*+] |\d+\. )/.test( - trimmed, - ); -} - -function findContainedRecoveryPrefixReplayLength( - previousText: string, - continuationText: string, -): number { - // Only consider replaying the *immediate* tail of the previous response. - // Earlier matches would let a coincidental substring far above the - // truncation point silently delete legitimate continuation text. - const previousTail = - previousText.length > RECOVERY_CONTAINED_TAIL_LOOKBACK_CHARS - ? previousText.slice(-RECOVERY_CONTAINED_TAIL_LOOKBACK_CHARS) - : previousText; - - // The contained-prefix path is intended *only* for replayed Markdown blocks - // (tables, headings, fenced code) that providers re-emit when resuming after - // MAX_TOKENS. Prose replays — even ones that briefly coincide with the - // previous tail — are out of scope: dropping them would silently lose user- - // visible content. Require a structural anchor at the very start of the - // continuation before considering any contained-prefix match at all. - if (!startsWithMarkdownStructuralAnchor(continuationText)) { - return 0; - } - - // The anchor check above tolerates leading whitespace because some providers - // re-emit the replayed block with extra leading spaces/tabs. The actual - // substring match must use the *trimmed* continuation, otherwise a - // continuation like `" ### Heading"` would never match a previous tail - // containing `"### Heading"` (no leading whitespace). Track the offset so - // the returned length consumes the leading whitespace too — keeping the - // caller's `continuationText.slice(replayedLength)` invariant intact. - const leadingMatch = continuationText.match(/^\s+/); - const leadingWhitespaceLength = leadingMatch?.[0].length ?? 0; - const trimmedContinuation = continuationText.slice(leadingWhitespaceLength); - - const maxPrefix = Math.min( - previousTail.length, - trimmedContinuation.length, - RECOVERY_OVERLAP_MAX_SCAN_CHARS, - ); - - for (let length = maxPrefix; length > 0; length -= 1) { - const prefix = trimmedContinuation.slice(0, length); - if ( - byteLength(prefix) >= RECOVERY_CONTAINED_PREFIX_MIN_BYTES && - previousTailContainsAtLineBoundary(previousTail, prefix) - ) { - return leadingWhitespaceLength + length; - } - } - - return 0; -} - -/** - * Symmetric line-boundary check for the contained-prefix scan: returns true - * iff `prefix` occurs in `previousTail` starting at index 0 or immediately - * after a newline. The structural-anchor check on the continuation side only - * enforces that the *continuation* starts at a Markdown block boundary; - * without this guard, a plain substring match could land mid-paragraph in - * `previousTail` (e.g. inside a code block that contains the literal string - * `"### Heading\nfoo"`) and silently strip legitimate continuation text. All - * occurrences are checked so a benign mid-paragraph hit doesn't shadow a real - * line-anchored replay later in the tail. - */ -function previousTailContainsAtLineBoundary( - previousTail: string, - prefix: string, -): boolean { - let searchFrom = 0; - while (searchFrom <= previousTail.length) { - const matchIndex = previousTail.indexOf(prefix, searchFrom); - if (matchIndex === -1) { - return false; - } - if (matchIndex === 0 || previousTail.charAt(matchIndex - 1) === '\n') { - return true; - } - searchFrom = matchIndex + 1; - } - return false; -} - -/** - * Compute the portion of `continuationText` that should be appended to - * `previousText` after a MAX_TOKENS recovery, stripping any overlap that the - * provider replayed at the boundary. - * - * The empty-input guard (`previousText.length === 0 || - * continuationText.length === 0`) is *defensive only*. The sole production - * caller is {@link appendRecoveryContinuationParts}, which already short- - * circuits when either side has no plain-text part — neither branch of the - * guard can fire from production code. It exists so that anyone reusing this - * helper directly (e.g. a future unit test, a refactor that bypasses the - * caller's filter) cannot crash or read out of bounds. We deliberately leave - * the guard in place rather than rely on the caller's invariant alone. - */ -function getRecoveryContinuationSuffix( - previousText: string, - continuationText: string, -): string { - if (previousText.length === 0 || continuationText.length === 0) { - return continuationText; - } - - if ( - previousText.endsWith(continuationText) && - isSignificantRecoveryOverlap(continuationText) - ) { - return ''; - } - - const maxOverlap = Math.min( - previousText.length, - continuationText.length, - RECOVERY_OVERLAP_MAX_SCAN_CHARS, - ); - - // Worst-case complexity here is O(n²): up to RECOVERY_OVERLAP_MAX_SCAN_CHARS - // iterations, each calling `previousText.endsWith(overlap)` plus - // `byteLength(overlap)` (both O(m)). At the current 4000-char scan cap that - // is ~16M char-ops per recovery event, which is fine because recovery is - // rare and the cap is small. If the cap ever grows materially, this can be - // rewritten with a precomputed Z-array / failure function on - // `continuationText` to scan once instead of repeatedly slicing/comparing. - for (let length = maxOverlap; length > 0; length -= 1) { - const overlap = continuationText.slice(0, length); - if ( - isSignificantRecoveryOverlap(overlap) && - previousText.endsWith(overlap) - ) { - return continuationText.slice(length); - } - } - - // Providers/models frequently resume a MAX_TOKENS recovery from an anchor - // that appears near the tail of the previous response, rather than from the - // exact last byte. Drop that replayed leading prefix before coalescing the - // recovery model turn into durable history; otherwise later turns inherit - // duplicated Markdown tables/prose even if the live UI suppresses them. - const containedPrefixLength = findContainedRecoveryPrefixReplayLength( - previousText, - continuationText, - ); - if (containedPrefixLength > 0) { - const replayedPrefix = continuationText.slice(0, containedPrefixLength); - let suffix = continuationText.slice(containedPrefixLength); - if ( - suffix.length > 0 && - replayedPrefix.endsWith('\n') && - !previousText.endsWith('\n') && - !suffix.startsWith('\n') - ) { - suffix = `\n${suffix}`; - } - return suffix; - } - - return continuationText; -} - -/** - * Join already-delivered text to the continuation that resumes it, dropping - * any tail the model replayed. - * - * The single definition of "merged turn text" for the transport-continuation - * path. Both the durable JSONL record and in-memory history are built from one - * call to this (see `processStreamResponse`), so the two storage layers cannot - * drift apart if the dedup rule ever changes — the same reason the - * `willPersistToHistory` gate is a shared binding rather than two copies of - * one expression. - */ -function mergeDeliveredPrefix( - deliveredText: string, - continuationText: string, -): string { - return ( - deliveredText + - getRecoveryContinuationSuffix(deliveredText, continuationText) - ); -} - -function isPlainTextPart(part: Part | undefined): part is Part & { - text: string; -} { - // Delegate to the shared predicate used by normal history consolidation - // (see `isValidNonThoughtTextPart` below) so the recovery-merge path and - // the consolidated-history path agree on what counts as "plain text". - // Keeping the type predicate here gives callers `part.text: string` - // narrowing; the underlying checks (thought, thoughtSignature, function*, - // inlineData, fileData) live in one place. - return part !== undefined && isValidNonThoughtTextPart(part); -} - -function getPlainTextFromParts(parts: Part[] | undefined): string { - return (parts ?? []) - .filter(isPlainTextPart) - .map((part) => part.text) - .join(''); -} - -/** - * Sanitize the previous-response tail before embedding it inside the - * `...` block. - * - * If the model's own truncated output happened to contain the literal - * closing delimiter (e.g. while generating XML/HTML examples), the - * recovery prompt's structure would break — the model would see a - * prematurely closed tag and misinterpret the suffix boundary. We - * neutralize any literal opening/closing delimiter occurrences by - * inserting a zero-width space between the angle bracket and the rest - * of the tag. The text remains visually identical to the model and - * preserves the recovery instruction's intent, but no longer collides - * with our delimiter scan. - */ -function sanitizeRecoverySuffixTail(tail: string): string { - if ( - !tail.includes('') && - !tail.includes('') - ) { - return tail; - } - return tail - .replace(/<\/previous_response_suffix>/g, '<​/previous_response_suffix>') - .replace(//g, '<​previous_response_suffix>'); -} - -/** - * Build a recovery user-turn from the text the model already produced. - * - * Shared by both continuation paths: output-token truncation (which reads the - * partial turn back out of history) and mid-stream transport cuts (which - * cannot, because a text-only partial is deliberately never persisted — see - * `processStreamResponse`). `lead` states the cause; everything after it is - * identical so the two paths cannot drift in how they fence the suffix. - */ -function buildRecoveryMessageFromText(lead: string, previousText: string) { - if (previousText.trim().length === 0) { - return lead; - } - - const rawTail = - previousText.length > OUTPUT_RECOVERY_TAIL_CHARS - ? previousText.slice(-OUTPUT_RECOVERY_TAIL_CHARS) - : previousText; - const tail = sanitizeRecoverySuffixTail(rawTail); - - return ( - `${lead}\n\n` + - 'The previous assistant response ended with this exact suffix. ' + - 'Do not repeat any line, table row, code line, or prose that already ' + - 'appears in it; output only text that comes after this suffix:\n\n' + - '\n' + - tail + - '\n' - ); -} - -function buildOutputRecoveryMessage(previousModelTurn: Content | undefined) { - return buildRecoveryMessageFromText( - OUTPUT_RECOVERY_MESSAGE, - previousModelTurn?.role === 'model' - ? getPlainTextFromParts(previousModelTurn.parts) - : '', - ); -} - -/** - * Coalesce a recovery continuation turn into the preceding (truncated) model - * turn, dropping any replayed overlap. - * - * Coupling with `processStreamResponse`. This function assumes the parts - * arrays it receives were produced by {@link GeminiChat.processStreamResponse} - * — i.e. all plain-text streaming chunks from a given turn have been - * consolidated in place into a single text part via `lastPart.text += - * part.text`. The dedup logic only inspects the *last* plain-text part of - * `previousParts` and the *first* plain-text part of `continuationParts`, so - * if a future refactor of `processStreamResponse` ever emits multiple adjacent - * unconsolidated text parts per turn, this function would compare the - * continuation against only the trailing fragment and miss real overlaps with - * earlier fragments. Both functions live in this file precisely so the - * coupling is reviewable in a single window. - * - * Return-value shape. The returned array preserves the *shape convention* of - * `processStreamResponse` output: `[thoughtPart?, ...consolidatedTextParts, - * ...nonTextParts]`. {@link GeminiChat.coalesceRecoveryPairs} relies on this - * by feeding the merged result back as `previousParts` on the next recovery - * iteration; if the shape ever diverges, multi-iteration recovery dedup would - * fail silently against the wrong part. - */ -function appendRecoveryContinuationParts( - previousParts: Part[] | undefined, - continuationParts: Part[] | undefined, -): Part[] { - const mergedParts = [...(previousParts ?? [])]; - const nextParts = [...(continuationParts ?? [])]; - - // `processStreamResponse` orders parts as - // `[thoughtPart?, ...consolidatedHistoryParts]`, so for thinking models the - // first element of `nextParts` is the recovery turn's thought, not its - // plain-text continuation. Similarly the previous truncated turn may end - // with a non-text part. Scan both sides for the dedup-relevant plain-text - // anchor instead of locking onto the boundary indices, otherwise thinking - // models leak duplicated text into durable history because the dedup block - // gets skipped wholesale. - const previousTextIndex = findLastPlainTextPartIndex(mergedParts); - const continuationTextIndex = nextParts.findIndex(isPlainTextPart); - - if (previousTextIndex >= 0 && continuationTextIndex >= 0) { - const previousTextPart = mergedParts[previousTextIndex] as Part & { - text: string; - }; - const continuationTextPart = nextParts[continuationTextIndex] as Part & { - text: string; - }; - const suffix = getRecoveryContinuationSuffix( - previousTextPart.text, - continuationTextPart.text, - ); - if (suffix.length > 0) { - // Allocate a fresh part rather than mutating in place: `mergedParts` - // shares element references with the caller's history slot, and any - // downstream caller that cached a `part` reference would observe the - // mutation. Cheap allocation; eliminates a fragile invariant. - mergedParts[previousTextIndex] = { - ...previousTextPart, - text: previousTextPart.text + suffix, - }; - } - // Drop the matched continuation text part: a non-empty suffix has already - // been appended above, and an empty suffix means the part was a pure - // replay of the previous tail and should be discarded so it does not - // duplicate into history. Hoist any non-text parts that preceded the - // matched text on the continuation side (typically the recovery turn's - // thought) so they land *before* the merged text part — thinking-model - // providers (Gemini 2.5+, Anthropic, OpenAI o-series) validate - // thought-signature provenance and expect a thought to precede the - // content it generated. Trailing non-text parts (tool calls etc.) keep - // their position via the final `[...mergedParts, ...nextParts]` concat. - const leadingNonTextParts = nextParts.splice(0, continuationTextIndex); - nextParts.shift(); - if (leadingNonTextParts.length > 0) { - mergedParts.splice(previousTextIndex, 0, ...leadingNonTextParts); - } - } - - return [...mergedParts, ...nextParts]; -} - -function findLastPlainTextPartIndex(parts: Part[]): number { - for (let i = parts.length - 1; i >= 0; i -= 1) { - if (isPlainTextPart(parts[i])) { - return i; - } - } - return -1; -} - -/** - * Options for retrying on rate-limit throttling errors returned as stream content. - * Starts at 60s to match DashScope's per-minute quota window, then backs off - * across repeated stream-side throttling errors. - * 10 retries aligns with Claude Code's retry behavior. - */ -const RATE_LIMIT_RETRY_OPTIONS = { - maxRetries: 10, - initialDelayMs: 60000, - maxDelayMs: 5 * 60 * 1000, -}; - -/** - * Creates a promise that resolves after the specified delay, but can be - * resolved early by calling the returned `skip` function. - * - * If an `AbortSignal` is provided and it fires before the delay completes, - * the promise rejects so the caller's `await` throws and normal error - * propagation takes over (e.g. the retry loop breaks and the generator exits). - */ -function delay( - delayMs: number, - signal?: AbortSignal, -): { - promise: Promise; - skip: () => void; -} { - let resolveRef: () => void; - let timeoutId: ReturnType; - - const promise = new Promise((resolve, reject) => { - resolveRef = resolve; - - if (signal?.aborted) { - reject(signal.reason); - return; - } - - timeoutId = setTimeout(resolve, delayMs); - - signal?.addEventListener( - 'abort', - () => { - clearTimeout(timeoutId); - reject(signal.reason); - }, - { once: true }, - ); - }); - - return { - promise, - skip: () => { - clearTimeout(timeoutId); - resolveRef(); - }, - }; -} - -/** - * Returns true if the response is valid, false otherwise. - * - * The DashScope provider may return the last 2 chunks as: - * 1. A choice(candidate) with finishReason and empty content - * 2. Empty choices with usage metadata - * We'll check separately for both of these cases. - */ -function isValidResponse(response: GenerateContentResponse): boolean { - if (response.usageMetadata) { - return true; - } - - if (response.candidates === undefined || response.candidates.length === 0) { - return false; - } - - if (response.candidates.some((candidate) => candidate.finishReason)) { - return true; - } - - const content = response.candidates[0]?.content; - return content !== undefined && isValidContent(content); -} - -export function isValidNonThoughtTextPart(part: Part): boolean { - return ( - typeof part.text === 'string' && - !part.thought && - !part.thoughtSignature && - // Technically, the model should never generate parts that have text and - // any of these but we don't trust them so check anyways. - !part.functionCall && - !part.functionResponse && - !part.inlineData && - !part.fileData - ); -} - -function isValidContent(content: Content): boolean { - if (content.parts === undefined || content.parts.length === 0) { - return false; - } - for (const part of content.parts) { - if (part === undefined || Object.keys(part).length === 0) { - return false; - } - if (!isValidContentPart(part)) { - return false; - } - } - return true; -} - -function isValidContentPart(part: Part): boolean { - const isInvalid = - !part.thought && - !part.thoughtSignature && - part.text !== undefined && - part.text === '' && - part.functionCall === undefined; - - return !isInvalid; -} - -const UPSTREAM_DEGRADED_PLACEHOLDER = '(request timeout)'; - -function degradedPlaceholderError(): InvalidStreamError { - return new InvalidStreamError( - 'Model response is an upstream fail-fast placeholder.', - 'UPSTREAM_DEGRADED_RESPONSE', - ); -} - -function isDegradedPlaceholderTurn(content: Content): boolean { - const parts = content.parts ?? []; - return ( - parts.length > 0 && - parts.every( - (part) => - part.functionCall === undefined && - (part.thought || part.text !== undefined), - ) && - parts - .filter((part) => !part.thought) - .map((part) => part.text ?? '') - .join('') - .trim() === UPSTREAM_DEGRADED_PLACEHOLDER - ); -} - -async function* rejectDegradedPlaceholderResponse( - stream: AsyncGenerator, -): AsyncGenerator { - const pending: GenerateContentResponse[] = []; - let text = ''; - let passthrough = false; - - for await (const chunk of stream) { - if (passthrough) { - yield chunk; - continue; - } - - const parts = chunk.candidates?.[0]?.content?.parts ?? []; - if ( - parts.some( - (part) => - part.functionCall !== undefined || - (!part.thought && part.text === undefined), - ) - ) { - yield* pending; - pending.length = 0; - yield chunk; - passthrough = true; - continue; - } - - const chunkText = parts - .filter((part) => !part.thought) - .map((part) => part.text ?? '') - .join(''); - if (pending.length === 0 && chunkText === '') { - yield chunk; - continue; - } - - pending.push(chunk); - text += chunkText; - const trimmed = text.trim(); - if (trimmed && !UPSTREAM_DEGRADED_PLACEHOLDER.startsWith(trimmed)) { - yield* pending; - pending.length = 0; - passthrough = true; - } - } - - if (passthrough) return; - if (text.trim() === UPSTREAM_DEGRADED_PLACEHOLDER) { - throw degradedPlaceholderError(); - } - yield* pending; -} - -/** - * Validates the history contains the correct roles. - * - * @throws Error if the history does not start with a user turn. - * @throws Error if the history contains an invalid role. - */ -function validateHistory(history: Content[]) { - for (const content of history) { - if (content.role !== 'user' && content.role !== 'model') { - throw new Error(`Role must be user or model, but got ${content.role}.`); - } - } -} - -/** - * Extracts the curated (valid) history from a comprehensive history. - * - * @remarks - * The model may sometimes generate invalid or empty contents(e.g., due to safety - * filters or recitation). Extracting valid turns from the history - * ensures that subsequent requests could be accepted by the model. - */ -function extractCuratedHistory(comprehensiveHistory: Content[]): Content[] { - if (comprehensiveHistory === undefined || comprehensiveHistory.length === 0) { - return []; - } - const curatedHistory: Content[] = []; - const length = comprehensiveHistory.length; - let i = 0; - while (i < length) { - if (comprehensiveHistory[i].role === 'user') { - appendCuratedContent(curatedHistory, comprehensiveHistory[i]); - i++; - } else { - const modelOutput: Content[] = []; - let isValid = true; - while (i < length && comprehensiveHistory[i].role === 'model') { - modelOutput.push(comprehensiveHistory[i]); - if (isValid && !isValidContent(comprehensiveHistory[i])) { - isValid = false; - } - i++; - } - if (isValid) { - curatedHistory.push( - ...modelOutput.filter((turn) => !isDegradedPlaceholderTurn(turn)), - ); - } - } - } - return curatedHistory; -} - -function appendCuratedContent( - curatedHistory: Content[], - content: Content, -): void { - const lastIndex = curatedHistory.length - 1; - const lastContent = lastIndex >= 0 ? curatedHistory[lastIndex] : undefined; - - if (content.role === 'user' && lastContent?.role === 'user') { - curatedHistory[lastIndex] = { - ...lastContent, - parts: [...(lastContent.parts ?? []), ...(content.parts ?? [])], - }; - return; - } - - curatedHistory.push(content); -} - -function copyContentContainer(content: Content): Content { - return { - ...content, - ...(content.parts ? { parts: content.parts.map(copyPartContainer) } : {}), - }; -} - -function copyPartContainer(part: Part): Part { - const nested = getFunctionResponseParts(part); - if (!nested) return { ...part }; - return { - ...part, - functionResponse: { - ...part.functionResponse, - parts: nested.map((inner) => ({ ...inner })), - }, - }; -} - -function stripThoughtPartsFromContent(content: Content): Content | null { - if (!content.parts) { - return content; - } - - const parts = content.parts.filter((part) => !(part as Part).thought); - if (parts.length === 0) { - return null; - } - - return { - ...content, - parts, - }; -} - -const PROTOCOL_TAG_PREFIXES = [ - '\s*<\/function>/iy; - -function hasLeakedToolCallTags(text: string): boolean { - let inString = false; - let escaped = false; - for (let i = 0; i < text.length; i++) { - const char = text[i]; - if (inString) { - if (escaped) escaped = false; - else if (char === '\\') escaped = true; - else if (char === '"') inString = false; - } else if (char === '"') { - inString = true; - } else if (char === '}' || char === ']') { - LEAKED_TOOL_CALL_TAGS.lastIndex = i; - if (LEAKED_TOOL_CALL_TAGS.test(text)) return true; - } - } - return false; -} - -class LeadingProtocolTagLeakDetector { - private state: 'detecting' | 'json' | 'clean' | 'leaked' = 'detecting'; - private buffer = ''; - - accept(text: string): string { - if (this.state === 'clean') return text; - if (this.state === 'leaked') return ''; - - this.buffer += text; - if (this.state === 'json') return ''; - const candidate = this.buffer.trimStart().toLowerCase(); - if (!candidate) return ''; - if (PROTOCOL_TAG_PREFIXES.some((prefix) => prefix.startsWith(candidate))) { - return ''; - } - - for (const prefix of PROTOCOL_TAG_PREFIXES) { - if ( - candidate.startsWith(prefix) && - /[\s/>]/.test(candidate[prefix.length] ?? '') - ) { - this.state = 'leaked'; - this.buffer = ''; - return ''; - } - } - if (candidate.startsWith('{')) { - this.state = 'json'; - return ''; - } - if (candidate.startsWith('[')) { - const normalized = candidate.replace(/\s/g, ''); - if (normalized === '[') return ''; - if (normalized.startsWith('[{')) { - this.state = 'json'; - return ''; - } - } - - return this.release(); - } - - finish(): string { - if (this.state === 'json') { - if (hasLeakedToolCallTags(this.buffer)) { - this.state = 'leaked'; - this.buffer = ''; - return ''; - } - return this.release(); - } - if (this.state !== 'detecting') return ''; - const candidate = this.buffer.trimStart().toLowerCase(); - if ( - candidate && - PROTOCOL_TAG_PREFIXES.some((prefix) => prefix.startsWith(candidate)) - ) { - this.state = 'leaked'; - this.buffer = ''; - return ''; - } - return this.release(); - } - - private release(): string { - const output = this.buffer; - this.state = 'clean'; - this.buffer = ''; - return output; - } - - get leaked(): boolean { - return this.state === 'leaked'; - } - - get blockingOutput(): boolean { - return this.state !== 'clean'; - } -} - -/** - * Default error text used when a synthesized `functionResponse` has to stand - * in for a real tool result that never made it back into history (e.g. the - * process crashed between the partial-tool_use push and tool completion, or - * the user hit Ctrl+Y before the in-flight tool finished and the scheduler's - * `onAllToolCallsComplete` was a single-shot that already fired into an - * `isResponding` early-return). - */ -export const ORPHAN_TOOL_USE_REPAIR_REASON = - 'Tool execution result was not recorded — likely interrupted by network ' + - 'failure, abort, or process exit. Treat as failure and retry if needed.'; - -/* - * ============================================================================ - * Partial-tool_use repair subsystem — canonical design note. - * ============================================================================ - * - * Every comment block elsewhere in this file that mentions one of the - * concepts below points back here. Per-site comments should be one or two - * lines stating WHAT the local code does; the WHY lives here. - * - * --- The wedge ---------------------------------------------------------- - * - * Anthropic-compatible backends (Anthropic, DeepSeek, …) reject a request - * whose `user[tool_result]` blocks are not at the HEAD of the user message - * immediately following the `model[tool_use]` they answer: - * - * "tool_use_id ... must have a corresponding tool_use block in the - * previous message" - * - * Without a matching pair the session is unrecoverable — `stripOrphanedUser - * EntriesFromHistory` only strips trailing user entries, so a lost tool_use - * cannot be resurrected and the next send 400s repeatedly. - * - * --- The race classes that produce dangling tool_uses -------------------- - * - * Race A (Ctrl+Y mid-flight): user retries before the in-flight tool - * finishes. The scheduler's `onAllToolCallsComplete` is single-shot - * per batch and would otherwise leave the tool stuck in - * `completed-but-not-submitted` forever. - * Race B (process crash / OOM mid-flight): the JSONL transcript captures - * the dangling `model[fc]` and `--resume` rehydrates it. - * Race C (network drop between `content_block_stop` of a tool_use and - * the terminal `message_stop`): `processStreamResponse` re-throws - * after we have already yielded a `functionCall` chunk, so the React - * scheduler is on its way to submit a real `functionResponse` while - * in-memory history has no matching `model[fc]`. - * - * --- The two-layer fix --------------------------------------------------- - * - * (1) Persist the partial assistant turn at the failure point in - * `processStreamResponse` (`this.history.push({role: 'model', parts: - * [...]})` plus the `pendingPartialAssistantTurnIndex` / - * `pendingPartialAssistantRecord` markers) so the matching - * `model[fc]` is on disk and in memory when the late `user[fr]` - * arrives. - * (2) Repair any remaining dangling `model[fc]` whose - * `user[fr]` never landed (`repairOrphanedToolUseTurns`): - * - SYNTHESIZE an `error` fr for ids with no matching response; - * - HOIST the real fr into the immediately-adjacent user turn - * when it landed in a non-adjacent later turn; - * - DROP duplicate fr copies for the same id. - * Then `useGeminiStream.handleCompletedTools` dedupes the - * scheduler's late real result against `chat.history` so the - * synthetic and the real result never collide on the wire. - * - * --- Partial-push marker lifecycle --------------------------------------- - * - * Set together on (streamError + hasToolCall + hasContent) inside - * `processStreamResponse`. Cleared together by `popPartialIfPushed` on a - * retryable error rollback, or flushed together to JSONL by the outer - * `finally` after the retry loop exits. Defense-in-depth: every - * history-mutation method (clearHistory / addHistory / setHistory / - * truncateHistory / stripThoughtsFromHistory / - * stripOrphanedUserEntriesFromHistory) resets both markers in lockstep so - * a stale index can't shift onto an unrelated model turn and cause - * `popPartialIfPushed` to splice the wrong entry. Any single-field reset - * is a bug. - * ============================================================================ - */ - -/** - * Walk `history` left-to-right and close every dangling - * tool_use ↔ tool_result pair. For each `model[functionCall]`: - * - SYNTHESIZE an `error` `functionResponse` for ids with no match; - * - HOIST a real fr from a non-adjacent later user turn into the - * adjacent one; - * - drop duplicate fr copies for the same id. - * - * Mutates `history` in place. Returns the synthesized (callId, name) - * pairs so the React scheduler's dedup can drop late real results for - * those ids; hoisted ids are NOT returned (the real fr is still in - * history, scheduler dedup handles them naturally). See the canonical - * note above `ORPHAN_TOOL_USE_REPAIR_REASON`. qwen-code analogue of - * upstream Claude Code's `yieldMissingToolResultBlocks`. - */ -/** Location of a `functionResponse` part within `history`. */ -interface FrLocation { - turnIdx: number; - partIdx: number; - part: Part; -} - -/** - * Output of the scan phase for a single `model[functionCall]` turn at - * `modelIdx`. `expected` maps each `functionCall.id` to its tool name, - * `matched` maps that same id to ALL locations of matching - * `functionResponse` parts across the consecutive user turns that - * follow, and `scanEnd` is one past the last user turn visited. - */ -interface ScanResult { - modelIdx: number; - expected: Map; - matched: Map; - scanEnd: number; - adjacentIdx: number; -} - -/** Decision-phase output: exact mutations the next phase will apply. */ -interface RepairPlan { - modelIdx: number; - scanEnd: number; - adjacentIdx: number; - synthesizeIds: Array<[string, string]>; - hoistedParts: Part[]; - removalTargets: Array<{ turnIdx: number; partIdx: number }>; - droppedDuplicates: Array<{ callId: string; name: string }>; -} - -/** - * SCAN — collect every `functionCall.id → name` from the model turn at - * `modelIdx` and EVERY `functionResponse.id → location` from the - * consecutive user turns that follow. Pure read. Storing all locations - * (not just the first) is what lets the decision phase drop duplicates. - */ -function scanModelTurn(history: Content[], modelIdx: number): ScanResult { - const expected = new Map(); - for (const part of history[modelIdx]?.parts ?? []) { - const fc = part.functionCall; - if (fc?.id) expected.set(fc.id, fc.name ?? 'unknown'); - } - - const matched = new Map(); - let scanIdx = modelIdx + 1; - while ( - scanIdx < history.length && - history[scanIdx]?.role === 'model' && - isDegradedPlaceholderTurn(history[scanIdx]) - ) { - scanIdx++; - } - const adjacentIdx = scanIdx; - while (scanIdx < history.length && history[scanIdx]?.role === 'user') { - const parts = history[scanIdx].parts ?? []; - for (let pIdx = 0; pIdx < parts.length; pIdx++) { - const part = parts[pIdx]; - const id = part.functionResponse?.id; - if (id) { - const list = matched.get(id); - if (list) list.push({ turnIdx: scanIdx, partIdx: pIdx, part }); - else matched.set(id, [{ turnIdx: scanIdx, partIdx: pIdx, part }]); - } - } - scanIdx++; - } - - return { modelIdx, expected, matched, scanEnd: scanIdx, adjacentIdx }; -} - -/** - * DECISION — classify each expected id: no match → SYNTHESIZE; first - * match adjacent → SKIP relocation; first match non-adjacent → HOIST. - * Every duplicate beyond the first is always dropped. Pure compute. - */ -function planRepair(scan: ScanResult): RepairPlan { - const synthesizeIds: Array<[string, string]> = []; - const hoistedParts: Part[] = []; - const removalTargets: Array<{ turnIdx: number; partIdx: number }> = []; - const droppedDuplicates: Array<{ callId: string; name: string }> = []; - - const adjacentIdx = scan.adjacentIdx; - for (const [id, name] of scan.expected) { - const locations = scan.matched.get(id); - if (!locations || locations.length === 0) { - synthesizeIds.push([id, name]); - continue; - } - // First copy is the canonical survivor — payloads should be - // identical for the same callId; if they differ, the wire is - // already corrupt and the backend rejects regardless. - const survivor = locations[0]!; - if (survivor.turnIdx !== adjacentIdx) { - hoistedParts.push(survivor.part); - removalTargets.push({ - turnIdx: survivor.turnIdx, - partIdx: survivor.partIdx, - }); - } - for (let k = 1; k < locations.length; k++) { - removalTargets.push({ - turnIdx: locations[k]!.turnIdx, - partIdx: locations[k]!.partIdx, - }); - droppedDuplicates.push({ callId: id, name }); - } - } - - return { - modelIdx: scan.modelIdx, - scanEnd: scan.scanEnd, - adjacentIdx: scan.adjacentIdx, - synthesizeIds, - hoistedParts, - removalTargets, - droppedDuplicates, - }; -} - -/** - * MUTATION — apply the plan to `history` in place. Returns the count - * of new user turns inserted (0 or 1) so the outer loop can advance its - * cursor. - * - * Order: (1) splice removal targets desc-by-desc, (2) drop empty user - * turns after the resolved adjacent turn, (3) HEAD-insert at that user - * turn OR splice a new user turn there. The HEAD insert is - * load-bearing (mirrors upstream `hoistToolResults`) — see the - * canonical note for why tail-append re-triggers the wedge. - */ -function applyRepair( - history: Content[], - plan: RepairPlan, - reason: string, -): { insertedBefore: number } { - if (plan.synthesizeIds.length === 0 && plan.removalTargets.length === 0) { - return { insertedBefore: 0 }; - } - - const syntheticParts: Part[] = plan.synthesizeIds.map(([callId, name]) => ({ - functionResponse: { id: callId, name, response: { error: reason } }, - })); - const partsToInject: Part[] = [...syntheticParts, ...plan.hoistedParts]; - - // (1) Splice removal targets, descending so indices stay valid. - const removals = [...plan.removalTargets].sort((a, b) => { - if (a.turnIdx !== b.turnIdx) return b.turnIdx - a.turnIdx; - return b.partIdx - a.partIdx; - }); - for (const loc of removals) { - const turnParts = history[loc.turnIdx].parts; - if (turnParts) turnParts.splice(loc.partIdx, 1); - } - - // (2) Drop now-empty user turns after the resolved adjacent turn. - // Preserve the adjacent turn even if empty — we'll rewrite it - // below. - const adjacentIdx = plan.adjacentIdx; - for (let j = plan.scanEnd - 1; j > adjacentIdx; j--) { - if (history[j]?.role === 'user' && (history[j].parts?.length ?? 0) === 0) { - history.splice(j, 1); - } - } - - if (partsToInject.length === 0) return { insertedBefore: 0 }; - - // (3) Place new parts at the head of the adjacent user turn, OR - // insert a fresh user turn at the resolved adjacency. - const next = history[adjacentIdx]; - if (next?.role === 'user') { - const existing = next.parts ?? []; - const firstNonFr = existing.findIndex((part) => !part.functionResponse); - const insertAt = firstNonFr === -1 ? existing.length : firstNonFr; - next.parts = [ - ...existing.slice(0, insertAt), - ...partsToInject, - ...existing.slice(insertAt), - ]; - return { insertedBefore: 0 }; - } - history.splice(adjacentIdx, 0, { role: 'user', parts: partsToInject }); - return { insertedBefore: 1 }; -} - -export interface RepairOrphanedToolUseOptions { - preserveCallIds?: ReadonlySet; -} - -/** - * Forward-walk `history`, planning and applying the repair for each - * `model[functionCall]` turn in turn. Iteration is index-based and the - * cursor advances by the count of user turns inserted ahead of it so - * a freshly-injected turn isn't re-visited. - * - * Splitting scan / decision / mutation into separate functions keeps - * each phase auditable in isolation — index drift can only happen in - * `applyRepair`, the only function that mutates `history`. - */ -export function repairOrphanedToolUseTurns( - history: Content[], - reason: string = ORPHAN_TOOL_USE_REPAIR_REASON, - options?: RepairOrphanedToolUseOptions, -): { - injected: Array<{ callId: string; name: string }>; - droppedDuplicates: Array<{ callId: string; name: string }>; -} { - const injected: Array<{ callId: string; name: string }> = []; - const droppedDuplicates: Array<{ callId: string; name: string }> = []; - const preserveCallIds = options?.preserveCallIds; - - for (let i = 0; i < history.length; i++) { - if (history[i].role !== 'model') continue; - - const scan = scanModelTurn(history, i); - if (scan.expected.size === 0) continue; - - const plan = planRepair(scan); - if (preserveCallIds && preserveCallIds.size > 0) { - plan.synthesizeIds = plan.synthesizeIds.filter( - ([id]) => !preserveCallIds.has(id), - ); - } - if (plan.synthesizeIds.length === 0 && plan.removalTargets.length === 0) { - continue; - } - - const { insertedBefore } = applyRepair(history, plan, reason); - // Only synthesized ids feed `injected` — hoisted ids reference real - // frs that were ALREADY in history before this pass (just - // relocated), so the scheduler's dedup naturally handles them. - for (const [callId, name] of plan.synthesizeIds) { - injected.push({ callId, name }); - } - droppedDuplicates.push(...plan.droppedDuplicates); - // Advance past any freshly-inserted user turn so the outer loop - // doesn't revisit it. Keeps the walk linear-time. - i += insertedBefore; - } - - return { injected, droppedDuplicates }; -} - -/** - * Chat session that enables sending messages to the model with previous - * conversation context. - * - * @remarks - * The session maintains all the turns between user and model. - */ -const SESSION_START_CONTEXT_SENTINEL_START = - ''; -const SESSION_START_CONTEXT_HEADER = 'SessionStart additional context'; - -function buildSessionStartContextBlock(extraInstruction: string): string { - return `\n\n${SESSION_START_CONTEXT_SENTINEL_START}\n${SESSION_START_CONTEXT_HEADER}:\n${extraInstruction}\n${SESSION_START_CONTEXT_SENTINEL_END}`; -} - -function stripTrailingSessionStartContextBlock( - systemInstruction: string, -): string { - const startIndex = systemInstruction.lastIndexOf( - `\n\n${SESSION_START_CONTEXT_SENTINEL_START}\n${SESSION_START_CONTEXT_HEADER}:\n`, - ); - if (startIndex === -1) { - return systemInstruction; - } - - const endIndex = systemInstruction.indexOf( - `\n${SESSION_START_CONTEXT_SENTINEL_END}`, - startIndex, - ); - if (endIndex === -1) { - return systemInstruction; - } - - return systemInstruction.slice(0, startIndex); -} - -export class GeminiChat { - // A promise to represent the current state of the message being sent to the - // model. - private sendPromise: Promise = Promise.resolve(); - - /** - * Per-chat last-prompt-token-count, populated from `usageMetadata` on each - * model response. Used by the compaction threshold check so that subagents - * (which intentionally don't write to the global telemetry singleton) can - * still make compaction decisions based on their *own* context size. - */ - private lastPromptTokenCount = 0; - private lastPromptTokenCountIsEstimated = false; - - /** - * Per-chat output-token count from the previous model response. The - * previous response is appended to local history after `promptTokenCount` - * was reported, so steady-state prompt estimates add this value to avoid - * under-counting the next request near the hard compaction threshold. - */ - private lastOutputTokenCount = 0; - - /** - * Route identity (model + auth type + endpoint; see - * Config.getModelRouteIdentity) of the content generator that produced - * the counts above. API-reported sizes are wire-specific: one route's - * count cannot size another route's serialization (#9454). Undefined - * until the first count is recorded. - */ - private tokenCountsRouteKey: string | undefined = undefined; - - /** - * Token counts retained for routes other than the one currently owning - * the slots above, keyed by route identity (#9506). Crossing routes - * retains the current slots here and adopts the target's entry back - * instead of destroying the value: API-reported sizes are per-route - * state that a later turn on the same route still needs — most - * critically the session-token-limit gate, whose keyed read would - * otherwise see 0 after any foreign-route touch between turns. - * Invariant: never holds an entry for {@link tokenCountsRouteKey}. - */ - private readonly tokenCountsByRouteKey = new Map< - string, - { - promptTokenCount: number; - promptTokenCountIsEstimated: boolean; - outputTokenCount: number; - cachedContentTokenCount: number; - } - >(); - - /** - * Number of consecutive auto-compaction failures for this chat. The - * cheap-gate NOOPs once this reaches MAX_CONSECUTIVE_FAILURES (default 3) - * until a successful compress (forced or not) resets it to 0. Replaces the - * single-shot hasFailedCompressionAttempt lock that previously disabled - * auto-compaction for the rest of the session on any failure. - * - * SEMANTICS (R5.3): this counter tracks "non-force, non-hard-rescue - * consecutive failures", NOT every failure literally. - * - Auto-compaction failures (cheap-gate path): increment by 1. - * - Manual `/compress` failures: skipped (`force=true` → `!force` - * guard in the failure branch). - * - Hard-tier rescue failures: skipped here because force=true bypasses - * this breaker; bounded separately by hardRescueFailureCount. - * - Reactive overflow failures: explicitly incremented in the overflow - * handler so N repeated reactive failures still trip this breaker. - * - * If you're debugging "why is hard-rescue firing but the counter is 0", - * that's by design. - */ - private consecutiveFailures = 0; - - /** - * Number of failed hard-tier rescue attempts for this chat. Hard rescue is - * forced and therefore bypasses the cheap-gate breaker, so it needs its own - * bound to avoid spending one compression side-query on every send when - * history repeatedly cannot shrink. NOOP counts toward this bound because - * it leaves the prompt oversized and would otherwise spend one compression - * side-query on every send. COMPRESSED resets this unless the - * post-compression hard-limit guard still rejects the send. - */ - private hardRescueFailureCount = 0; - - /** - * Partial-push markers — index of the in-memory `model[partial fc]` - * and the matching deferred JSONL record. See the canonical note - * above `ORPHAN_TOOL_USE_REPAIR_REASON` for the lifecycle and the - * wedge they prevent. - */ - private pendingPartialAssistantTurnIndex: number | null = null; - private pendingPartialAssistantRecord: - | Parameters[0] - | null = null; - - private readonly imagePayloadStore = new InMemoryImagePayloadStore(); - - /** - * Monotonically counts user-content pushes that survived into history. - * Incremented when `sendMessageStream` pushes the user content and decremented - * only if that same push is rolled back on a setup-time failure. Auto- - * compression mutates history length but never touches this counter, so a - * caller (the Retry strip/restore in client.ts) can snapshot it and tell - * whether the re-submitted content actually landed — a history-length delta - * can't, since compression shrinks history independently of the push. - */ - private userContentPushCount = 0; - private manualPlanExitNoticesEnabled = false; - - /** - * Reset both partial-push markers in lockstep. Every history-mutation - * site uses this — single-field resets are a bug because the fields - * are always paired by lifecycle. - */ - private clearPendingPartialState(): void { - this.pendingPartialAssistantTurnIndex = null; - this.pendingPartialAssistantRecord = null; - } - - private popPendingPartialAssistantTurn(): void { - const idx = this.pendingPartialAssistantTurnIndex; - if (idx === null) return; - if (this.history.length > idx && this.history[idx]?.role === 'model') { - this.history.splice(idx, 1); - } else { - debugLogger.warn( - `[PARTIAL_POP] Splice skipped: idx=${idx}, ` + - `historyLength=${this.history.length}, ` + - `roleAtIdx=${this.history[idx]?.role ?? 'undefined'}`, - ); - } - this.clearPendingPartialState(); - } - - /** - * Creates a new GeminiChat instance. - * - * @param config - The configuration object. - * @param generationConfig - Optional generation configuration. - * @param history - Optional initial conversation history. - * @param chatRecordingService - Optional recording service. If provided, chat - * messages will be recorded. - * @param telemetryService - Optional UI telemetry service. When provided, - * prompt token counts are reported on each API response. Pass `undefined` - * for sub-agent chats to avoid overwriting the main agent's context usage. - */ - constructor( - private readonly config: Config, - private readonly generationConfig: GenerateContentConfig = {}, - private history: Content[] = [], - private readonly chatRecordingService?: ChatRecordingService, - private readonly telemetryService?: UiTelemetryService, - ) { - validateHistory(history); - this.redactApprovedPlansFromLoadedHistory(); - } - - enableManualPlanExitNotices(): void { - this.manualPlanExitNoticesEnabled = true; - } - - /** - * Identity of the currently active model route. Optional chaining keeps - * partial Config test mocks (`{} as Config`) from throwing on count - * reads/writes; a missing identity degrades to one stable key, i.e. no - * route-change invalidation. - */ - private currentRouteKey(): string { - return this.config.getModelRouteIdentity?.() ?? ''; - } - - /** - * Make the single-slot token counters describe the route identified by - * `targetRouteKey` (default: the active route). Counts recorded for a - * different route must not anchor admission, output clamping, or - * compression decisions for this one (`/model` switches rebuild the - * content generator but keep this chat instance; #9454). - * - * The crossing is NON-DESTRUCTIVE (#9506): the current slots are - * retained in {@link tokenCountsByRouteKey} under their own route key, - * and the target's retained entry — if any — is adopted back into the - * slots. Zeroing a foreign count outright let any foreign-route touch - * between two turns destroy the value before the session-token-limit - * gate (the only yield site of `SessionTokenLimitExceeded`) could read - * it back keyed by its request route. With retention, a route with no - * counts of its own still falls back to the history-walk estimate - * (slots 0), with reactive overflow recovery as the safety net, while a - * turn returning to a route that has counts reads the exact - * API-reported values. - * - * Defaults to comparing against the ACTIVE route (lazy reads on the - * getters). Send paths pass the route the upcoming request actually - * targets so a foreign count cannot anchor that request's decisions even - * when the active route owns it — e.g. an exact `\0` route selector, or - * a non-exact send whose `model` param overrides the active model. - * - * The telemetry mirror is display-only state: it is resynchronized here - * (adopted or zeroed alongside the slots), so between a `/model` switch - * and the next chat touch the UI counters may briefly show the previous - * route's counts. Decision paths never read the mirror, only the - * route-aware chat getters above. - */ - private adoptTokenCountsForRoute(targetRouteKey?: string): void { - if ( - this.lastPromptTokenCount === 0 && - this.lastOutputTokenCount === 0 && - this.tokenCountsByRouteKey.size === 0 - ) { - return; - } - // Resolve the active-route default only AFTER the zero-count fast path: - // computing a route identity (SHA-256 digest + config lookups) on every - // count read while both counts are 0 (and nothing is retained) would - // defeat the guard above. - targetRouteKey ??= this.currentRouteKey(); - if (this.tokenCountsRouteKey === targetRouteKey) { - return; - } - const retained = this.tokenCountsByRouteKey.get(targetRouteKey); - if (retained) { - this.tokenCountsByRouteKey.delete(targetRouteKey); - this.retainCurrentTokenCounts(); - debugLogger.debug( - `[token-counts] restoring retained counts for route ${targetRouteKey}`, - ); - this.lastPromptTokenCount = retained.promptTokenCount; - this.lastPromptTokenCountIsEstimated = - retained.promptTokenCountIsEstimated; - this.lastOutputTokenCount = retained.outputTokenCount; - this.tokenCountsRouteKey = targetRouteKey; - this.telemetryService?.setLastPromptTokenCount(retained.promptTokenCount); - this.telemetryService?.setLastCachedContentTokenCount( - retained.cachedContentTokenCount, - ); - return; - } - debugLogger.debug( - `[token-counts] route changed; retaining counts recorded for ` + - `${this.tokenCountsRouteKey ?? 'unknown'} (now ${targetRouteKey})`, - ); - this.retainCurrentTokenCounts(); - // Raw assignment on purpose: setLastPromptTokenCount would re-attribute - // the zero slot to the ACTIVE route. The slot is attributed to the - // TARGET route instead so it can never collide with the just-retained - // entry (retained under the evicted slot's key, which differs from the - // target) — a colliding key would make the next keyed read for the - // retained route early-return the zero slot without consulting the map. - this.lastPromptTokenCount = 0; - this.lastPromptTokenCountIsEstimated = false; - this.lastOutputTokenCount = 0; - this.tokenCountsRouteKey = targetRouteKey; - // Keep the telemetry mirror in sync, or the UI context counters - // and compression banners keep reading the foreign count. The cached - // content count belongs to the same foreign route's last response. - this.telemetryService?.setLastPromptTokenCount(0); - this.telemetryService?.setLastCachedContentTokenCount(0); - } - - /** - * Save the current slots into {@link tokenCountsByRouteKey} under their - * owning route key so a later read keyed back to that route restores the - * exact API-reported values. Zero slots carry nothing worth retaining; - * the telemetry mirror still holds the owning route's cached-content - * count at this point, so it is captured here too. - */ - private retainCurrentTokenCounts(): void { - if ( - this.tokenCountsRouteKey === undefined || - (this.lastPromptTokenCount === 0 && this.lastOutputTokenCount === 0) - ) { - return; - } - if (this.tokenCountsByRouteKey.size >= MAX_RETAINED_ROUTE_COUNTS) { - const oldestKey = this.tokenCountsByRouteKey.keys().next().value; - if (oldestKey !== undefined) { - this.tokenCountsByRouteKey.delete(oldestKey); - } - } - this.tokenCountsByRouteKey.set(this.tokenCountsRouteKey, { - promptTokenCount: this.lastPromptTokenCount, - promptTokenCountIsEstimated: this.lastPromptTokenCountIsEstimated, - outputTokenCount: this.lastOutputTokenCount, - // Optional chaining keeps partial telemetry test mocks from throwing - // (same convention as currentRouteKey's Config lookups). - cachedContentTokenCount: - this.telemetryService?.getLastCachedContentTokenCount?.() ?? 0, - }); - } - - /** - * Most recent prompt-token count reported by the model for *this* chat, - * mirroring the value in {@link UiTelemetryService} for the main session. - * Subagent chats have no telemetry service wired but still need a per-chat - * count for compaction decisions, so this is always populated regardless - * of whether the global telemetry is updated. - */ - getLastPromptTokenCount(targetRouteKey?: string): number { - this.adoptTokenCountsForRoute(targetRouteKey); - return this.lastPromptTokenCount; - } - - /** Previous model-response tokens used by the next prompt estimate. */ - getLastOutputTokenCount(): number { - this.adoptTokenCountsForRoute(); - return this.lastOutputTokenCount; - } - - /** - * Builds request contents for the content generator without deep-cloning the - * whole chat history. This is an internal hot path: long sessions can make a - * full `structuredClone` larger than the remaining V8 heap headroom. - * - * Public history readers still use {@link getHistory}, which returns a - * defensive deep copy for caller mutation safety. - */ - private getRequestHistory(currentUserContent?: Content): Content[] { - const curatedHistory = extractCuratedHistory(this.history); - const { maxRecentImages, imagePayloadThreshold } = resolveCompactionTuning( - this.config.getChatCompression(), - ); - let replaced: ReturnType = []; - if (countAllInlineImages(curatedHistory) >= imagePayloadThreshold) { - const skipEntry = currentUserContent - ? curatedHistory.find( - (c) => - c === currentUserContent || - (c.role === 'user' && - currentUserContent.parts?.some((p) => c.parts?.includes(p))), - ) - : undefined; - replaced = replaceImagePayloadsInPlace( - curatedHistory, - this.imagePayloadStore, - skipEntry, - ); - } - const requestHistory = curatedHistory.map(copyContentContainer); - const reattachParts = buildReattachParts( - replaced, - maxRecentImages, - requestHistory, - this.imagePayloadStore, - ); - if (reattachParts.length > 0) { - const last = requestHistory.at(-1); - if (last?.role === 'user') { - last.parts = [...(last.parts ?? []), ...reattachParts]; - } else { - requestHistory.push({ role: 'user', parts: reattachParts }); - } - } - return requestHistory; - } - - private getRequestHistoryForRoute( - currentUserContent: Content | undefined, - supportedModalities: InputModalities, - ): Content[] { - return slimCompactionInput( - this.getRequestHistory(currentUserContent), - supportedModalities, - ).slimmedHistory; - } - - /** - * Seed the last-prompt-token-count for chats created with inherited - * history (forks, subagents, speculation). Without this, the auto-compress - * threshold check sees `0` and refuses to compress — so the first API call - * can 400 from oversized history. Callers pass the parent chat's - * `getLastPromptTokenCount()` here. This also clears any remembered - * previous-response output token count because the seeded prompt count - * comes from a different chat instance and should not inherit this chat's - * last response size. - */ - setLastPromptTokenCount(count: number, isEstimated = false): void { - this.lastPromptTokenCount = count; - this.lastPromptTokenCountIsEstimated = isEstimated; - this.lastOutputTokenCount = 0; - this.tokenCountsRouteKey = this.currentRouteKey(); - // A fresh count supersedes anything this route retained while another - // route owned the slots. Without the delete this writer alone among the - // count writers would leave an entry for tokenCountsRouteKey behind, - // breaking the map's documented invariant (#9506). - this.tokenCountsByRouteKey.delete(this.tokenCountsRouteKey); - } - - isLastPromptTokenCountEstimated(): boolean { - this.adoptTokenCountsForRoute(); - return this.lastPromptTokenCountIsEstimated; - } - - private promptCountIsEstimateDerived(): boolean { - return ( - this.lastPromptTokenCount === 0 || this.lastPromptTokenCountIsEstimated - ); - } - - /** - * Seed the restored prompt and previous-response output token counts in one - * step. Resume restores chat history plus both counters and their provenance - * from the same checkpoint, so callers must avoid the normal - * setLastPromptTokenCount() clearing behavior. - */ - seedResumeTokenCounts( - promptTokenCount: number, - outputTokenCount: number, - isEstimated = false, - ): void { - this.lastPromptTokenCount = Number.isFinite(promptTokenCount) - ? Math.max(0, promptTokenCount) - : 0; - this.lastPromptTokenCountIsEstimated = isEstimated; - this.lastOutputTokenCount = Number.isFinite(outputTokenCount) - ? Math.max(0, outputTokenCount) - : 0; - // Attribute the seeded counts to the active route so a model switch - // after resume invalidates them like any API-reported count. (Detecting - // a route that already differed at save time requires persisting route - // identity in the session transcript; tracked as a follow-up to #9454.) - this.tokenCountsRouteKey = this.currentRouteKey(); - // A fresh seed supersedes any count this route retained while another - // route owned the slots (#9506). - this.tokenCountsByRouteKey.delete(this.tokenCountsRouteKey); - } - - /** - * Attempt to compress this chat's history. - * - * Returns the compression info regardless of outcome. On a successful - * compaction (`COMPRESSED`), this method has already mutated the chat's - * history, recorded the event to `chatRecordingService` (if wired and - * unless `options.deferChatCompressionRecord` is set), and updated both - * the per-chat token count and (when wired) the global telemetry singleton. - * Deferred callers are responsible for recording after their own - * post-compression guards pass. - */ - async tryCompress( - promptId: string, - force = false, - signal?: AbortSignal, - options?: TryCompressOptions, - ): Promise { - // Counts from a pre-switch route must not anchor compression admission - // or sizing for this route (#9454). In-send callers pass the request - // route so the adoption never re-adopts the active route's retained - // counts mid-send (#9506). - this.adoptTokenCountsForRoute(options?.requestRouteKey); - const originalTokenCountIsEstimated = - options?.originalTokenCountOverride === undefined && - this.promptCountIsEstimateDerived(); - const originalTokenCount = originalTokenCountIsEstimated - ? (options?.precomputedEffectiveTokens ?? - estimateContentTokens( - options?.pendingUserMessage - ? [...this.getHistoryShallow(true), options.pendingUserMessage] - : this.getHistoryShallow(true), - resolveSlimmingConfig(this.config.getChatCompression()) - .imageTokenEstimate, - )) - : (options?.originalTokenCountOverride ?? this.lastPromptTokenCount); - debugLogger.debug( - `[compaction] token-count provenance: prompt_id=${promptId}, ` + - `originalTokenCount=${originalTokenCount}, ` + - `estimated=${originalTokenCountIsEstimated}`, - ); - const service = new ChatCompressionService(); - const { newHistory, info } = await service.compress(this, { - promptId, - force, - config: this.config, - consecutiveFailures: this.consecutiveFailures, - originalTokenCount, - pendingUserMessage: options?.pendingUserMessage, - precomputedEffectiveTokens: options?.precomputedEffectiveTokens, - requestGenerationConfig: options?.requestGenerationConfig, - trigger: options?.trigger, - customInstructions: options?.customInstructions, - signal, - }); - - // ChatCompressionService reads the keyless count getters, which adopt - // the ACTIVE route — flipping the slots away from the request route - // adopted above whenever the two differ (non-exact override sends). - // Re-adopt the request route so neither the COMPRESSED stamp below nor - // the caller's post-compression sizing anchors on the flipped - // attribution (#9506). - this.adoptTokenCountsForRoute(options?.requestRouteKey); - - if (info.compressionStatus === CompressionStatus.COMPRESSED && newHistory) { - // ChatCompressionService owns provenance. Keep a conservative fallback - // for older/custom implementations that omit the field, but preserve an - // explicit authoritative `false`. - info.newTokenCountIsEstimated ??= true; - if (!options?.deferChatCompressionRecord) { - this.chatRecordingService?.recordChatCompression({ - info, - compressedHistory: newHistory, - }); - } - this.setHistory(newHistory); - debugLogger.debug('[FILE_READ_CACHE] clear after auto tryCompress'); - this.config.getFileReadCache().clear(); - // Compression rewrote the shared history every retained entry sizes, - // so ALL retained counts are stale — not just the current route's. - // Drop them, or a later keyed read adopts a pre-compression count and - // the session-token-limit gate blocks a prompt that fits the - // compressed history (#9506). - this.tokenCountsByRouteKey.clear(); - this.setLastPromptTokenCount( - info.newTokenCount, - info.newTokenCountIsEstimated, - ); - // setLastPromptTokenCount re-keyed the fresh count to the ACTIVE - // route, but in-send callers compress for the REQUEST route: the - // session-token-limit gate reads by that key (client.ts's sole - // SessionTokenLimitExceeded yield site), and a request that ends - // without a usage report (abort, 400 — the reactive-overflow path - // exists for exactly those) never stamps a count of its own. Re-key - // the fresh count to the request route, retaining it under the - // active key first: the compressed history is shared, so the count - // must anchor BOTH routes' next gate reads (#9506). - if ( - options?.requestRouteKey && - this.tokenCountsRouteKey !== options.requestRouteKey - ) { - this.retainCurrentTokenCounts(); - this.tokenCountsRouteKey = options.requestRouteKey; - // Same invariant as the other count writers: the fresh count - // supersedes anything the request route retained. - this.tokenCountsByRouteKey.delete(options.requestRouteKey); - } - this.telemetryService?.setLastPromptTokenCount(info.newTokenCount); - // Reset the consecutive-failure counter on success so a forced /compress - // (or any successful compaction) recovers a chat whose breaker had - // tripped. - this.consecutiveFailures = 0; - this.hardRescueFailureCount = 0; - } else if (isCompressionFailureStatus(info.compressionStatus)) { - // Track failed attempts (only count if not forced) so we stop spending - // compression-API calls on a chat that can't shrink after - // MAX_CONSECUTIVE_FAILURES strikes in a row. - if (!force) { - this.consecutiveFailures += 1; - if (this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { - debugLogger.warn( - `[compaction] circuit breaker tripped after ${this.consecutiveFailures} consecutive failures (cheap-gate path); auto-compaction will NOOP until a successful force compaction resets the counter.`, - ); - } - } - } - - return info; - } - - /** - * Fast, rule-based compression without any LLM side-query. - * - * Force-runs microcompaction (clear old tool results + media, keep recent N) - * then strips thinking parts from all model turns. - */ - compressFast(): { - info: ChatCompressionInfo; - microcompactMeta?: MicrocompactMeta; - } { - // A pre-switch route's count must not anchor fast-compression sizing - // for the active route (#9454). - this.adoptTokenCountsForRoute(); - // Use the same estimator on both sides so the NOOP gate compares - // apples to apples. The API-authoritative lastPromptTokenCount is - // then adjusted by the estimated delta — never replaced wholesale. - const beforeEstimate = estimateContentTokens(this.history); - const projectRoot = this.config.getProjectRoot(); - const targetDir = this.config.getTargetDir?.() ?? projectRoot; - - // Step 1: force microcompaction (clear old tool results + media) - const mcResult = microcompactHistory( - this.history, - null, - this.config.getClearContextOnIdle(), - { - force: true, - preserveReadFileResult: (filePath) => - isManagedMemoryPath(filePath, projectRoot, targetDir), - }, - ); - const mcMeta = mcResult.meta; - - // Step 2: strip thinking parts from model turns - const newHistory = mcResult.history - .map((c) => (c.role === 'model' ? stripThoughtPartsFromContent(c) : c)) - .filter((c): c is Content => c !== null); - - const afterEstimate = estimateContentTokens(newHistory); - - if (afterEstimate >= beforeEstimate) { - const apiBaseline = this.lastPromptTokenCount || beforeEstimate; - return { - info: { - originalTokenCount: apiBaseline, - newTokenCount: apiBaseline, - compressionStatus: CompressionStatus.NOOP, - }, - }; - } - - const reduction = beforeEstimate - afterEstimate; - const apiBaseline = this.lastPromptTokenCount || beforeEstimate; - const baselineIsEstimated = this.promptCountIsEstimateDerived(); - const adjustedTokenCount = Math.max(0, apiBaseline - reduction); - - debugLogger.debug( - `[compaction] fast token-count provenance: ` + - `originalTokenCount=${apiBaseline}, estimated=${baselineIsEstimated}`, - ); - - const info: ChatCompressionInfo = { - originalTokenCount: apiBaseline, - newTokenCount: adjustedTokenCount, - newTokenCountIsEstimated: true, - compressionStatus: CompressionStatus.COMPRESSED, - triggerReason: 'manual', - }; - - this.chatRecordingService?.recordChatCompression({ - info, - compressedHistory: newHistory, - }); - logChatCompression( - this.config, - makeChatCompressionEvent({ - tokens_before: info.originalTokenCount, - tokens_after: info.newTokenCount, - }), - ); - this.setHistory(newHistory); - this.lastPromptTokenCount = adjustedTokenCount; - this.lastPromptTokenCountIsEstimated = true; - this.tokenCountsRouteKey = this.currentRouteKey(); - // Fast compression rewrote the shared history every retained entry - // sizes, so ALL retained counts are stale — the other routes' entries - // describe the same pre-compression history (#9506). - this.tokenCountsByRouteKey.clear(); - this.telemetryService?.setLastPromptTokenCount(adjustedTokenCount); - this.consecutiveFailures = 0; - - return { info, microcompactMeta: mcMeta }; - } - - setSystemInstruction(sysInstr: string) { - this.generationConfig.systemInstruction = sysInstr; - } - - setSessionStartContext(extraInstruction: string) { - const trimmed = extraInstruction.trim(); - if (!trimmed) { - return; - } - - const current = this.generationConfig.systemInstruction; - let baseInstruction = ''; - if (typeof current === 'string') { - baseInstruction = stripTrailingSessionStartContextBlock(current); - } else if (current) { - baseInstruction = getCustomSystemPrompt(current); - baseInstruction = stripTrailingSessionStartContextBlock(baseInstruction); - } - const contextBlock = buildSessionStartContextBlock(trimmed); - this.generationConfig.systemInstruction = `${baseInstruction}${contextBlock}`; - } - - applySessionStartContext( - extraInstruction: string, - _source: SessionStartSource, - ): void { - const trimmed = extraInstruction.trim(); - if (!trimmed) { - return; - } - - this.setSessionStartContext(trimmed); - } - - /** - * Sends a message to the model and returns the response in chunks. - * - * @remarks - * This method will wait for the previous message to be processed before - * sending the next message. - * - * @see {@link Chat#sendMessage} for non-streaming method. - * @param params - parameters for sending the message. - * @return The model's response. - * - * @example - * ```ts - * const chat = ai.chats.create({model: 'gemini-2.0-flash'}); - * const response = await chat.sendMessageStream({ - * message: 'Why is the sky blue?' - * }); - * for await (const chunk of response) { - * console.log(chunk.text); - * } - * ``` - */ - async sendMessageStream( - model: string, - params: SendMessageParameters, - prompt_id: string, - goalContext?: GoalTurnPermit, - options?: LlmChatSendOptions, - ): Promise> { - const turnGoalContext = goalContext ? { ...goalContext } : undefined; - const fullTurnRoute = model.endsWith('\0'); - const exactRoute = fullTurnRoute - ? await this.config - .getBaseLlmClient() - .resolveForModel(model.slice(0, -1), { failClosed: true }) - : undefined; - if (exactRoute) { - model = exactRoute.model; - } - // Both arms are one call: for a non-exact send `exactRoute` is - // undefined, and `resolvedModelIdentity`'s second parameter defaults to - // `getContentGeneratorConfig()` — including when passed an explicit - // undefined. Keeping a single call site means a future change to how - // the request route is identified cannot drift between the arms. - const requestRouteKey = this.config.getModelRouteIdentity( - model, - exactRoute?.contentGeneratorConfig, - ); - // Counts recorded for a route other than this request's target must not - // anchor its admission/clamp/compression decisions (#9454). Comparing - // against the REQUEST route — resolved above — keeps an exact `\0` - // route's decisions off the active route's counts, and a differing - // `model` param gets its own identity instead of borrowing the active - // route's. The crossing retains the current counts under their own - // route key so a later turn back on that route restores them (#9506). - this.adoptTokenCountsForRoute(requestRouteKey); - const requestModalities = - exactRoute?.contentGeneratorConfig.modalities ?? - this.config.getEffectiveInputModalities(); - - await this.sendPromise; - - let streamDoneResolver: () => void; - const streamDonePromise = new Promise((resolve) => { - streamDoneResolver = resolve; - }); - this.sendPromise = streamDonePromise; - - // Clear any partial-push marker left over from a prior unretryable - // break path — the marker is per-send; carrying it across sends - // would let the next send's retry catch wrongly pop a now-valid - // model entry sitting at the stale index. The deferred-record - // stash gets the same per-send reset for the same reason: a - // leftover from a prior unretryable break would otherwise get - // appended to JSONL by THIS send's retry-loop flush, attaching - // someone else's failed turn to this conversation. - this.clearPendingPartialState(); - - let compressionInfo: ChatCompressionInfo; - let requestContents: Content[]; - let userContentAdded = false; - let manualPlanExitNoticeVersion: number | undefined; - let manualPlanExitNoticeText: string | undefined; - - // Determine the ceiling for this turn's output request. The clamp below - // (see clampOutputTokensToWindow) sizes the actual max_tokens to the room - // left in the window, so output can never overflow the context limit and - // compaction thresholds run against the FULL window — no output - // reservation is subtracted (this replaces the #5957/#6266 reservation - // machinery; see the max-tokens-window-clamp design doc). - // - // The ceiling is the explicit user/subagent value when one is set - // (params.config.maxOutputTokens from subagents, samplingParams.max_tokens - // or QWEN_CODE_MAX_OUTPUT_TOKENS from user config), else - // defaultOutputCeiling(model) (the model's output limit clipped to - // OUTPUT_TOKEN_CEILING). - const cgConfigForThresholds = - exactRoute?.contentGeneratorConfig ?? - this.config.getContentGeneratorConfig(); - const parsedEnvMaxTokensForClamp = parsePositiveIntegerEnvValue( - process.env['QWEN_CODE_MAX_OUTPUT_TOKENS'], - ); - const explicitOutputCeiling: number | undefined = - params.config?.maxOutputTokens ?? - cgConfigForThresholds?.samplingParams?.max_tokens ?? - parsedEnvMaxTokensForClamp; - const outputCeiling: number = - explicitOutputCeiling ?? defaultOutputCeiling(model); - // Declared at function level so the MAX_TOKENS escalation path inside - // the generator closure can re-clamp against the same window and prompt - // estimate. - const contextWindowForClamp = - cgConfigForThresholds?.contextWindowSize ?? DEFAULT_TOKEN_LIMIT; - let promptTokensForClamp = 0; - - let currentUserContent: Content | undefined; - try { - // The send-lock above is held but the generator's `finally` (which - // resolves it) has not run yet. Any setup error before returning the - // generator must release the lock or subsequent sends will block forever - // at `await this.sendPromise`. - // Build the user content BEFORE compression so the cheap-gate can size - // the upcoming prompt — closes the "first send after inherited history" - // gap where `lastPromptTokenCount === 0` and the gate would otherwise - // see only the stale prior-turn count (0). - let userContent = createUserContent(params.message); - const toolOutputBudget = this.config.getToolOutputBatchBudget?.(); - if ( - toolOutputBudget !== undefined && - Number.isFinite(toolOutputBudget) && - userContent.parts - ) { - const [guarded] = enforceFunctionResponseBudget( - [ - { - callId: 'send-boundary', - toolName: 'tool-response-batch', - responseParts: userContent.parts, - }, - ], - toolOutputBudget, - ); - if (guarded.responseParts !== userContent.parts) { - debugLogger.warn( - `Tool response send guard reduced an unfinalized batch to ${toolOutputBudget} characters.`, - ); - userContent = { ...userContent, parts: guarded.responseParts }; - } - } - - // Hard-tier rescue: when the estimated prompt size is at or above the - // hard threshold (effectiveWindow - HARD_BUFFER), force compaction in - // this send instead of waiting for the API to reject the request as too - // large. - // - // We compute `effectiveTokens` ONCE here and pass it through to - // tryCompress → service.compress so the cheap-gate doesn't redo the - // estimation (which involves another `getHistory(true)` clone). This - // reuse also fixes a per-config-knob inconsistency: previously the - // hard-tier rescue used the default imageTokenEstimate while the - // cheap-gate inside tryCompress used the user's resolved value. - // (review #4168 R1.3 + R1.4) - // - // The cheap-gate consecutive-failure counter is NOT pre-reset here. - // force=true already bypasses that breaker, while hard-rescue itself is - // bounded by hardRescueFailureCount so persistent pre-send rescue - // failures fall through to reactive overflow after a few strikes. - // Thresholds gate on the full window: the output clamp guarantees the - // response fits, so nothing needs to be pre-reserved for it. - const { hard } = computeThresholds( - contextWindowForClamp, - this.config.getAutoCompactThreshold(), - ); - const imageTokenEstimate = resolveSlimmingConfig( - this.config.getChatCompression(), - ).imageTokenEstimate; - // When lastPromptTokenCount > 0, estimatePromptTokens uses the - // API-authoritative previous prompt count + the previous response's - // output token count + a tiny estimate of just the new user message. - // It does NOT touch the history at all in that branch, so skip the - // costly `getHistory(true)` clone on the steady-state path. - // The lastPromptTokenCount=0 branch (first send after --continue - // restore / subagent inheritance) walks history with a char/4 - // heuristic that can under-count by ~15-20K tokens; the reactive - // overflow recovery path inside the async iterator below (the - // `getContextLengthExceededInfo` → `tryCompress` → RETRY branch) - // is the documented safety net when this under-count causes - // hard-rescue to miss. - const effectiveTokens = estimatePromptTokens( - this.lastPromptTokenCount > 0 ? [] : this.getHistoryShallow(true), - userContent, - this.lastPromptTokenCount, - this.lastOutputTokenCount, - imageTokenEstimate, - ); - const isHardTier = effectiveTokens >= hard; - const shouldForceFromHard = - !exactRoute && - isHardTier && - this.hardRescueFailureCount < MAX_CONSECUTIVE_FAILURES; - const historyBeforeHardRescue = shouldForceFromHard - ? this.getHistoryShallow() - : undefined; - const lastPromptTokenCountBeforeHardRescue = this.lastPromptTokenCount; - const lastPromptTokenCountWasEstimatedBeforeHardRescue = - this.lastPromptTokenCountIsEstimated; - // The rescue's COMPRESSED stamp zeroes lastOutputTokenCount (via - // setLastPromptTokenCount), so the rollback below must restore the - // output half of the resurrected count pair alongside the prompt - // half, or the next turn's additive prompt estimate under-counts by - // the last response's size (#9506). - const lastOutputTokenCountBeforeHardRescue = this.lastOutputTokenCount; - // tryCompress re-stamps tokenCountsRouteKey to the ACTIVE route (via - // setLastPromptTokenCount on the success path) even though this send - // targets the REQUEST route — and hard-rescue only fires for - // non-exact sends, whose request key can differ from the active one. - // Capture the key so the rollback below restores the resurrected - // count's original route attribution along with the count itself. - const tokenCountsRouteKeyBeforeHardRescue = this.tokenCountsRouteKey; - // Snapshot the retention map too: the rescue's compression consumes - // retained entries mid-flight (ChatCompressionService's keyless getter - // reads adopt the active route, deleting-and-consuming its entry) and - // a successful compression clears the map outright. Without the - // snapshot the rollback would restore the slots but not the map, - // leaving the resurrected route's count nowhere (#9506). - const retainedTokenCountsBeforeHardRescue = new Map( - this.tokenCountsByRouteKey, - ); - const hardRescueFailureCountBeforeHardRescue = - this.hardRescueFailureCount; - if (shouldForceFromHard) { - debugLogger.warn( - `[compaction] hard-tier rescue triggered: prompt_id=${prompt_id}, effectiveTokens=${effectiveTokens}, hard=${hard}, hardRescueAttempt=${this.hardRescueFailureCount + 1}, consecutiveFailures=${this.consecutiveFailures}.`, - ); - } else if (isHardTier && !exactRoute) { - debugLogger.warn( - `[compaction] hard-tier rescue skipped after ${this.hardRescueFailureCount} failed attempts; relying on reactive overflow recovery. prompt_id=${prompt_id}, effectiveTokens=${effectiveTokens}, hard=${hard}.`, - ); - } - - if (exactRoute || (isHardTier && !shouldForceFromHard)) { - compressionInfo = { - originalTokenCount: effectiveTokens, - newTokenCount: effectiveTokens, - compressionStatus: CompressionStatus.NOOP, - }; - } else { - compressionInfo = await this.tryCompress( - prompt_id, - shouldForceFromHard, - params.config?.abortSignal, - { - pendingUserMessage: userContent, - precomputedEffectiveTokens: effectiveTokens, - requestGenerationConfig: params.config, - requestRouteKey, - deferChatCompressionRecord: shouldForceFromHard, - // Hard-rescue is force=true to bypass the cheap-gate breaker - // but it remains a semantically AUTOMATIC trigger. Tag the - // compactTrigger explicitly as 'auto' so PostCompact hooks are - // classified correctly while the pending user message preserves - // any active tool-call / response pairing. - trigger: shouldForceFromHard ? 'auto' : undefined, - }, - ); - } - const localPromptTokensAfterCompression = shouldForceFromHard - ? estimatePromptTokens( - this.lastPromptTokenCount > 0 ? [] : this.getHistoryShallow(true), - userContent, - this.lastPromptTokenCount, - this.lastOutputTokenCount, - imageTokenEstimate, - ) - : 0; - if ( - shouldStopAfterHardRescue( - shouldForceFromHard, - hard, - localPromptTokensAfterCompression, - ) - ) { - const message = getHardRescueFailureMessage( - effectiveTokens, - hard, - compressionInfo, - localPromptTokensAfterCompression, - ); - if (shouldForceFromHard) { - this.hardRescueFailureCount = - hardRescueFailureCountBeforeHardRescue + 1; - } - if ( - compressionInfo.compressionStatus === CompressionStatus.COMPRESSED && - historyBeforeHardRescue - ) { - // Hard-rescue compression mutates in-memory history before this - // guard can compare the compressed prompt size. If the compressed - // prompt is still too large to send, restore the pre-compression - // state. The JSONL compression checkpoint is intentionally not - // written because the send is about to be rejected. - this.setHistory(historyBeforeHardRescue); - this.lastPromptTokenCount = lastPromptTokenCountBeforeHardRescue; - this.lastPromptTokenCountIsEstimated = - lastPromptTokenCountWasEstimatedBeforeHardRescue; - this.lastOutputTokenCount = lastOutputTokenCountBeforeHardRescue; - this.tokenCountsRouteKey = tokenCountsRouteKeyBeforeHardRescue; - // Restore the retention map alongside the slots: the rescue's - // compression consumed/cleared entries mid-flight, and without - // the restore the resurrected route's count would survive - // nowhere — its next gate read would pass with 0 (#9506). The - // snapshot predates the rescue, so it already satisfies the - // invariant (no entry for the resurrected slot key). - this.tokenCountsByRouteKey.clear(); - for (const [ - retainedRouteKey, - retainedCounts, - ] of retainedTokenCountsBeforeHardRescue) { - this.tokenCountsByRouteKey.set(retainedRouteKey, retainedCounts); - } - this.telemetryService?.setLastPromptTokenCount( - lastPromptTokenCountBeforeHardRescue, - ); - } - const compressionStatus = - CompressionStatus[compressionInfo.compressionStatus] ?? - String(compressionInfo.compressionStatus); - debugLogger.warn( - `[compaction] hard-tier rescue stopped oversized prompt: ` + - `prompt_id=${prompt_id}, effectiveTokens=${effectiveTokens}, ` + - `hard=${hard}, localPromptTokensAfterCompression=` + - `${localPromptTokensAfterCompression}, compressionStatus=` + - `${compressionStatus}, newTokenCount=` + - `${compressionInfo.newTokenCount}, hardRescueFailureCount=` + - `${this.hardRescueFailureCount}, consecutiveFailures=` + - `${this.consecutiveFailures}. ${message}`, - ); - throw new Error(message); - } - if ( - shouldForceFromHard && - compressionInfo.compressionStatus === CompressionStatus.COMPRESSED - ) { - this.chatRecordingService?.recordChatCompression({ - info: compressionInfo, - compressedHistory: this.getHistoryShallow(), - }); - } - - if (this.manualPlanExitNoticesEnabled) { - const notice = this.config.takePendingManualPlanExitNotice(); - if (notice) { - manualPlanExitNoticeVersion = notice.version; - manualPlanExitNoticeText = getManualPlanExitSystemReminder( - notice.currentMode, - ); - userContent = { - ...userContent, - parts: [ - ...(userContent.parts ?? []), - { - text: manualPlanExitNoticeText, - }, - ], - }; - } - } - - // Add user content to history ONCE before any attempts. - this.history.push(userContent); - currentUserContent = userContent; - userContentAdded = true; - // Record that the user content landed (see `userContentPushCount`). The - // setup-error path below decrements this if it rolls the push back. - this.userContentPushCount++; - // Per-send orphan repair (belt-and-suspenders alongside the - // startChat load-time pass). Runs AFTER user content lands so a - // user-supplied tool_result closes the pair before we synthesize - // anything. An ordinary prompt that races a restore re-hang must - // still close the pair — `model[functionCall] → user[text]` is - // rejected by Anthropic-compatible providers. Restore itself sends - // the real functionResponse, so this pass is a no-op on that path. - const inlineRepair = repairOrphanedToolUseTurns( - this.history, - ORPHAN_TOOL_USE_REPAIR_REASON, - ); - if (inlineRepair.injected.length > 0) { - debugLogger.warn( - `[REPAIR] sendMessageStream inline pass synthesized ` + - `${inlineRepair.injected.length} functionResponse(s): ` + - inlineRepair.injected - .map((entry) => `${entry.name}(${entry.callId})`) - .join(', '), - ); - } - if (inlineRepair.droppedDuplicates.length > 0) { - debugLogger.warn( - `[REPAIR] sendMessageStream inline pass dropped ` + - `${inlineRepair.droppedDuplicates.length} duplicate ` + - `functionResponse(s): ` + - inlineRepair.droppedDuplicates - .map((entry) => `${entry.name}(${entry.callId})`) - .join(', '), - ); - } - requestContents = this.getRequestHistoryForRoute( - currentUserContent, - requestModalities, - ); - - // Window-clamp the output request AFTER compression has settled the - // history: max_tokens = min(ceiling, window − prompt − margin), floored - // at MIN_CLAMPED_OUTPUT_TOKENS. Computed here in the send path — not in - // the shared provider code — so the API-authoritative - // lastPromptTokenCount is in scope and side queries (which set their - // own maxOutputTokens via getBaseLlmClient()) stay exempt by - // construction. This makes `prompt + max_tokens ≤ window` an invariant - // on every main-turn request (issue #5950). - // - // When lastPromptTokenCount > 0 (steady state, or refreshed to - // newTokenCount by compression/resume), re-estimate from the counts — - // cheap, no history walk. When it is still 0, reuse the pre-push gate - // estimate: userContent is already in history here, so a fresh history - // walk would double-count it. Estimate-derived counts can omit the - // system prompt, tool definitions, and skill content (see - // estimatePromptTokens — "typically ~15-20K of under-estimate"). Some - // counts based on prior API usage already preserve part of that - // overhead, but conservatively double-counting it is safe and - // self-corrects when provider usage arrives. An under-count is the ONE - // way `prompt + max_tokens` can still overflow the window, so keep the - // pad until provider usage replaces the estimate. - promptTokensForClamp = - this.lastPromptTokenCount > 0 - ? estimatePromptTokens( - [], - userContent, - this.lastPromptTokenCount, - this.lastOutputTokenCount, - imageTokenEstimate, - /* conservative= */ true, - ) - : effectiveTokens; - if (this.promptCountIsEstimateDerived()) { - promptTokensForClamp += ESTIMATE_CLAMP_OVERHEAD_PAD; - debugLogger.debug( - `[clamp] estimate-derived prompt count; padded by ` + - `${ESTIMATE_CLAMP_OVERHEAD_PAD}: ` + - `promptTokensForClamp=${promptTokensForClamp}, ` + - `count=${this.lastPromptTokenCount}`, - ); - } - const clampedMaxOutputTokens = clampOutputTokensToWindow( - outputCeiling, - contextWindowForClamp, - promptTokensForClamp, - ); - params = { - ...params, - config: { - ...params.config, - maxOutputTokens: clampedMaxOutputTokens, - }, - }; - } catch (error) { - if (userContentAdded) { - this.history.pop(); - // The push above was rolled back, so undo its count too. - this.userContentPushCount--; - } - if (manualPlanExitNoticeVersion !== undefined) { - this.config.restorePendingManualPlanExitNotice( - manualPlanExitNoticeVersion, - ); - } - streamDoneResolver!(); - throw error; - } - - // eslint-disable-next-line @typescript-eslint/no-this-alias - const self = this; - return (async function* () { - const sleepInhibitorHandle = acquireSleepInhibitor( - self.config, - 'Qwen Code is streaming a model response', - ); - try { - // Surface a successful auto-compression to the caller as the first - // event in the stream. Failed/skipped compaction attempts are silent. - // Must be inside the try so that a consumer abandoning the stream - // immediately after this event still triggers the finally below; - // otherwise `streamDoneResolver` never fires and the next send hangs. - if ( - compressionInfo.compressionStatus === CompressionStatus.COMPRESSED - ) { - yield { - type: StreamEventType.COMPRESSED, - info: compressionInfo, - }; - } - - let lastError: unknown = new Error('Request failed after all retries.'); - let rateLimitRetryCount = 0; - let transientInvalidStreamRetryCount = 0; - let protocolTagLeakRetryCount = 0; - const totalInvalidStreamRetryCount = () => - transientInvalidStreamRetryCount + protocolTagLeakRetryCount; - // The armed attempt can be rescheduled by a competing retry path - // (rate limit, transport replay/continuation, reactive compression) - // before its outcome is known; the rescheduled attempt is still the - // last one the exhausted invalid-stream budget allows, so keep the - // one-shot quiet-completion acceptance armed for it (#9026). - const rearmQuietAcceptanceIfBudgetSpent = () => { - // Keyed to the transient bucket only: quiet completions surface - // as NO_TOOL_RESULT_PROGRESS (a transient type), so only a spent - // transient budget entitles the next attempt to acceptance. A - // tag-leak-only exhaustion must not arm — a quiet ending still - // has its full retry-first budget ahead of it (#7039). - if ( - transientInvalidStreamRetryCount >= - INVALID_STREAM_RETRY_CONFIG.transientMaxRetries - ) { - acceptQuietToolResultCompletionOnNextAttempt = true; - } - }; - let transportStreamRetryCount = 0; - // Continuation recovery for mid-stream socket closes (issue #7832). - // `transportContinuationText` accumulates every plain-text chunk this - // send has already handed to callers across all continuation attempts, - // so each attempt can show the model its own visible output and ask it - // to resume instead of replaying (which would duplicate that output). - // Attempts are folded in one at a time, each with any overlap it - // replayed stripped, so the buffer holds no fragment twice. - let transportContinuationCount = 0; - let transportContinuationText = ''; - // Text delivered by the attempt currently running, before it is folded - // into `transportContinuationText`. Kept separate so the overlap a - // continuation attempt replays is stripped once, at the attempt - // boundary where it occurs, rather than per chunk — the overlap scan is - // suffix-anchored and would eat legitimately repeated text mid-stream. - let transportAttemptText = ''; - // Text delivered *before* the attempt currently running. Empty unless - // a continuation is in flight. `processStreamResponse` only pushes the - // final attempt's own output to history, so this is what has to be - // prepended once the send succeeds, or the next turn would see the - // model's answer starting mid-sentence. - let transportContinuationPrefix = ''; - let reactiveCompressionAttempted = false; - let suppressNextRetryEvent = false; - let streamYieldedAnyChunk = false; - - // Read per-config overrides; fall back to built-in defaults. - const cgConfig = - exactRoute?.contentGeneratorConfig ?? - self.config.getContentGeneratorConfig(); - const requestOverrides = exactRoute - ? { - contentGenerator: exactRoute.contentGenerator, - retryAuthType: exactRoute.retryAuthType, - retryErrorCodes: exactRoute.retryErrorCodes, - } - : undefined; - const maxRateLimitRetries = - cgConfig?.maxRetries ?? RATE_LIMIT_RETRY_OPTIONS.maxRetries; - const retryInitialDelayMs = - cgConfig?.retryInitialDelayMs ?? - RATE_LIMIT_RETRY_OPTIONS.initialDelayMs; - const retryMaxDelayMs = - cgConfig?.retryMaxDelayMs ?? RATE_LIMIT_RETRY_OPTIONS.maxDelayMs; - const extraRetryErrorCodes = cgConfig?.retryErrorCodes; - - // Max output tokens escalation: when no user/env override is set and - // the model hits MAX_TOKENS, retry once with the escalated limit. - let maxTokensEscalated = false; - const parsedEnvMaxTokens = parsePositiveIntegerEnvValue( - process.env['QWEN_CODE_MAX_OUTPUT_TOKENS'], - ); - const hasUserMaxTokensOverride = - (cgConfig?.samplingParams?.max_tokens !== undefined && - cgConfig?.samplingParams?.max_tokens !== null) || - parsedEnvMaxTokens !== undefined; - // params.config.maxOutputTokens is set by the first-send clamp; the - // outputCeiling fallback is defensive and should not fire in practice. - const effectiveInitialMaxOutputTokens = - params.config?.maxOutputTokens ?? outputCeiling; - const escalatedLimit = clampOutputTokensToWindow( - OUTPUT_TOKEN_CEILING, - contextWindowForClamp, - promptTokensForClamp, - ); - const shouldEscalateMaxOutputTokens = - effectiveInitialMaxOutputTokens < escalatedLimit; - - let lastFinishReason: string | undefined; - - /** - * Contents for the next attempt. Identical to `requestContents` on - * every normal send; while a transport continuation is pending it - * appends the two synthetic turns that carry the delivered output and - * the instruction to resume from it. Built per attempt and never - * written to `self.history`, so the synthetic turns cannot leak into - * durable history, the JSONL transcript, or a later compression — - * unlike the MAX_TOKENS recovery loop, which has to route through - * history and clean up afterwards with `coalesceRecoveryPairs`. - */ - const buildAttemptContents = (): Content[] => - transportContinuationPrefix.length > 0 - ? [ - ...requestContents, - { - role: 'model', - parts: [{ text: transportContinuationPrefix }], - }, - createUserContent([ - { - text: buildRecoveryMessageFromText( - TRANSPORT_CONTINUATION_MESSAGE, - transportContinuationPrefix, - ), - }, - ]), - ] - : requestContents; - - /** - * Forget any in-flight continuation. - * - * Called from every branch that re-sends the *original* request, since - * those emit a `RETRY` without `isContinuation` and the UI drops the - * delivered text on that event. The request has to drop it too, or the - * resend would keep asking the model to resume output the caller no - * longer has — and a later success would merge that discarded text back - * into history, leaving the UI and history permanently out of step. - */ - const resetTransportContinuation = () => { - transportContinuationCount = 0; - transportContinuationText = ''; - transportAttemptText = ''; - transportContinuationPrefix = ''; - }; - - // Fold the running attempt's text into the accumulated buffer, - // stripping any overlap it replayed from the previous attempt's tail, - // so the accumulated buffer never contains text twice. - // - // Called on the cut exit only. The success exit merges the prefix into - // history and breaks, and nothing reads the buffer after the loop, so - // folding there would have no reader. A post-loop read added later - // (telemetry, a MAX_TOKENS-recovery guard) would be missing the final - // attempt's text and must fold on the success path too. - const foldTransportAttemptText = () => { - transportContinuationText += getRecoveryContinuationSuffix( - transportContinuationText, - transportAttemptText, - ); - transportAttemptText = ''; - }; - - let acceptQuietToolResultCompletionOnNextAttempt = false; - for (;;) { - transportAttemptText = ''; - let streamYieldedChunk = false; - let streamYieldedContentChunk = false; - // A cut that already delivered a `functionCall` cannot be continued - // from — see the continuation gate below. - let streamYieldedFunctionCall = false; - try { - if (suppressNextRetryEvent) { - // The branch that scheduled this attempt already emitted its own - // RETRY, and — if that RETRY was a fresh restart rather than a - // continuation — already called `resetTransportContinuation`. - // Resetting again here would clear the state of a continuation - // that is legitimately in flight. - suppressNextRetryEvent = false; - } else if ( - rateLimitRetryCount > 0 || - totalInvalidStreamRetryCount() > 0 || - transportStreamRetryCount > 0 || - transportContinuationCount > 0 - ) { - // A fresh-restart retry reaching this point means a branch that - // does not set `suppressNextRetryEvent` (rate limit, invalid - // stream) chose to re-send the original request. - resetTransportContinuation(); - yield { type: StreamEventType.RETRY }; - } - - const acceptQuietToolResultCompletion = - acceptQuietToolResultCompletionOnNextAttempt; - acceptQuietToolResultCompletionOnNextAttempt = false; - const stream = await self.makeApiCallAndProcessStream( - model, - buildAttemptContents(), - params, - prompt_id, - requestOverrides, - requestRouteKey, - turnGoalContext, - // Captured by value, so the attempt records exactly the prefix - // `buildAttemptContents()` just asked the model to resume from, - // even if a later branch resets the continuation. - transportContinuationPrefix.length > 0 - ? transportContinuationPrefix - : undefined, - acceptQuietToolResultCompletion, - ); - - lastFinishReason = undefined; - for await (const chunk of stream) { - if (hasCandidateOutput(chunk)) { - streamYieldedChunk = true; - streamYieldedAnyChunk = true; - } - if (hasNonThoughtCandidateParts(chunk)) { - streamYieldedContentChunk = true; - } - // Mirror the visible text into the continuation buffer as it is - // yielded. Reading it back off history is not an option on the - // transport path: processStreamResponse deliberately does NOT - // persist a text-only partial turn when the stream throws, so at - // the catch below history holds nothing about what the user - // already saw. - const chunkParts = chunk.candidates?.[0]?.content?.parts; - transportAttemptText += getPlainTextFromParts(chunkParts); - if (chunkParts?.some((part) => part.functionCall)) { - streamYieldedFunctionCall = true; - } - const fr = chunk.candidates?.[0]?.finishReason; - if (fr) lastFinishReason = fr; - yield { type: StreamEventType.CHUNK, value: chunk }; - } - - lastError = null; - // The merge itself now happens inside `processStreamResponse`, - // which folds the prefix into the parts before it writes either - // the JSONL record or the history turn (issue #8094). Merging - // again here would risk double-applying it: the dedup helper only - // strips a replayed prefix that clears its significance floor, so - // a short prefix would survive the second pass and be doubled. - transportContinuationPrefix = ''; - break; - } catch (error) { - lastError = error; - // This attempt is over; fold what it delivered into the running - // buffer before any branch below reads it. Doing this here rather - // than per chunk keeps the overlap scan anchored at the attempt - // boundary, which is the only place a replay can occur. - foldTransportAttemptText(); - - // Handle rate-limit / throttling errors returned as stream content. - // These arrive as StreamContentError with finish_reason="error_finish" - // from the pipeline, containing the throttling message in the content. - // Covers TPM throttling, GLM rate limits, and other provider throttling. - // Classify once per failed attempt; reused by the rate-limit - // diagnostics below and the transport-retry decision further down. - const classification = classifyRetryError(error, { - authType: cgConfig?.authType, - extraRetryErrorCodes, - }); - - // Permanent quota exhaustion (e.g. Bailian token-plan "1-week - // quota has been exhausted, will reset at ...") can arrive - // mid-stream as a StreamContentError, bypassing retryWithBackoff - // (which only wraps stream establishment). Fast-fail before the - // rate-limit branch: its 429 code would otherwise schedule a 1-5 - // minute delay on an error that cannot succeed until the reset - // time. Throws a plain Error (no .status) and skips model - // fallback, matching the retryWithBackoff fast-fail. - if (isQuotaExhaustedError(error)) { - debugLogger.warn('Quota exhausted mid-stream, fast-failing', { - retryPath: 'stream', - retryDecision: 'fail-fast', - errorKind: classification.kind, - classificationReason: classification.reason, - }); - throw new Error(formatQuotaExhaustedMessage(error), { - cause: error, - }); - } - - const isRateLimit = isRateLimitError(error, extraRetryErrorCodes); - if (isRateLimit) { - const details = getRateLimitErrorDetails(error); - // The classifier is observation-only here; stream retry control - // remains governed by isRateLimitError and the retry budget. - const diagnosticFields = { - classificationDiagnosis: classification.diagnosis, - errorKind: classification.kind, - classificationReason: classification.reason, - ...details, - }; - - if (rateLimitRetryCount < maxRateLimitRetries) { - // Discard any partial assistant turn from the failed attempt - // before scheduling the retry, so a stale partial does not leak - // into history or the JSONL transcript. - self.popPendingPartialAssistantTurn(); - rateLimitRetryCount++; - const delayMs = getRateLimitRetryDelayMs(rateLimitRetryCount, { - ...RATE_LIMIT_RETRY_OPTIONS, - initialDelayMs: retryInitialDelayMs, - maxDelayMs: retryMaxDelayMs, - error, - }); - const message = parseAndFormatApiError( - error instanceof Error ? error.message : String(error), - ); - debugLogger.warn('Rate limit retry scheduled', { - retryPath: 'stream', - retryDecision: 'retry', - attempt: rateLimitRetryCount, - maxRetries: maxRateLimitRetries, - retryDelayMs: delayMs, - ...diagnosticFields, - }); - const { promise: delayPromise, skip } = delay( - delayMs, - params.config?.abortSignal, - ); - yield { - type: StreamEventType.RETRY, - retryInfo: { - message, - attempt: rateLimitRetryCount, - maxRetries: maxRateLimitRetries, - delayMs, - skipDelay: skip, - }, - }; - await delayPromise; - rearmQuietAcceptanceIfBudgetSpent(); - continue; - } - - debugLogger.warn('Rate limit retry exhausted', { - retryPath: 'stream', - retryDecision: 'exhausted', - attempts: rateLimitRetryCount, - maxRetries: maxRateLimitRetries, - ...diagnosticFields, - }); - } - - // Replay only curated socket-level failures before any - // user-visible content has reached callers. Thinking-only - // output does not block the replay: thought parts are - // ephemeral (never recorded as the assistant's response in - // history), so retrying after them cannot duplicate visible - // output — and thinking models can spend minutes in that - // phase, exactly when gateways close long-lived SSE - // connections (#7832). - if ( - isRetryableStreamTransportError(classification) && - !streamYieldedContentChunk && - // `streamYieldedContentChunk` is per-attempt, so on its own it - // cannot tell "nothing has been delivered" from "this attempt - // was cut while thinking, after earlier attempts already put - // text on screen". Only the first is replayable; replaying the - // second discards output the caller is watching. The - // accumulated buffer is what distinguishes them, and it must be - // consulted here because this branch is checked before the - // continuation one below. - transportContinuationText.trim().length === 0 && - transportStreamRetryCount < - TRANSPORT_STREAM_RETRY_CONFIG.maxRetries - ) { - self.popPendingPartialAssistantTurn(); - transportStreamRetryCount++; - const delayMs = - TRANSPORT_STREAM_RETRY_CONFIG.initialDelayMs * - transportStreamRetryCount; - debugLogger.warn('Transport stream retry scheduled', { - retryPath: 'stream', - retryDecision: 'retry', - attempt: transportStreamRetryCount, - maxRetries: TRANSPORT_STREAM_RETRY_CONFIG.maxRetries, - retryDelayMs: delayMs, - yieldedNonContentChunks: streamYieldedChunk, - errorKind: classification.kind, - transportCode: classification.transportCode, - }); - yield { type: StreamEventType.RETRY }; - // A replay is a fresh restart, so anything a previous - // continuation had staged must go. The gate above now admits - // only an empty accumulated buffer, which leaves nothing for - // this to clear — it stays as an assertion of that invariant, - // so a future gate change cannot leak staged text into a - // restarted attempt. - resetTransportContinuation(); - suppressNextRetryEvent = true; - await delay(delayMs, params.config?.abortSignal).promise; - rearmQuietAcceptanceIfBudgetSpent(); - continue; - } - // Continuation recovery (issue #7832). Once answer text has been - // delivered, replaying is off the table — it would duplicate what - // the caller already has — but propagating is not the only - // alternative left. Gateways that cap SSE connection lifetime - // (DashScope closes at ~3-5 min) cut long generations partway - // through the answer, which is precisely when the replay gate is - // shut, so large outputs failed outright however many retries were - // configured. Instead of replaying, keep the delivered text and - // ask the model to continue from it — the same shape the MAX_TOKENS - // truncation path already uses: show the model its own partial - // output, inject a resume instruction, and signal the UI with - // `isContinuation` so it keeps its text buffer rather than - // discarding it. - // - // A cut that delivered a `functionCall` is excluded: injecting a - // user turn between a `functionCall` and its `functionResponse` - // produces a sequence providers reject (the same constraint the - // MAX_TOKENS recovery loop enforces via its `hasFunctionCall` - // check), and the scheduler's repair path already covers it. - const canContinueAfterTransportCut = - isRetryableStreamTransportError(classification) && - !streamYieldedFunctionCall && - transportContinuationText.trim().length > 0 && - transportContinuationCount < - TRANSPORT_STREAM_RETRY_CONFIG.maxContinuationRetries; - if (canContinueAfterTransportCut) { - self.popPendingPartialAssistantTurn(); - transportContinuationCount++; - // Everything delivered so far — across earlier continuation - // attempts too, since `transportContinuationText` accumulates - // and is never reset while continuing. Each attempt's own text - // was folded in at the catch above with its replayed overlap - // stripped, so this carries no fragment twice. - transportContinuationPrefix = transportContinuationText; - const delayMs = - TRANSPORT_STREAM_RETRY_CONFIG.initialDelayMs * - transportContinuationCount; - debugLogger.warn('Transport stream continuation scheduled', { - retryPath: 'stream', - retryDecision: 'continue', - attempt: transportContinuationCount, - maxRetries: - TRANSPORT_STREAM_RETRY_CONFIG.maxContinuationRetries, - retryDelayMs: delayMs, - errorKind: classification.kind, - transportCode: classification.transportCode, - deliveredChars: transportContinuationText.length, - }); - // `isContinuation` keeps the UI's text buffer, so the next - // attempt's chunks append to what is already on screen instead - // of replacing it. The delivered text and the resume - // instruction ride along in `buildAttemptContents()`. - yield { type: StreamEventType.RETRY, isContinuation: true }; - suppressNextRetryEvent = true; - await delay(delayMs, params.config?.abortSignal).promise; - rearmQuietAcceptanceIfBudgetSpent(); - continue; - } - if (isRetryableStreamTransportError(classification)) { - // Reached only when neither branch above fired: content was - // already delivered so replaying would duplicate it, or the - // replay budget is exhausted, or continuation is unavailable - // (function-call cut, no text to anchor on, or its own budget - // exhausted). - debugLogger.warn('Transport stream retry not taken', { - retryPath: 'stream', - retryDecision: streamYieldedContentChunk - ? 'skipped_after_content' - : 'exhausted', - attempts: transportStreamRetryCount, - maxRetries: TRANSPORT_STREAM_RETRY_CONFIG.maxRetries, - continuationAttempts: transportContinuationCount, - maxContinuationRetries: - TRANSPORT_STREAM_RETRY_CONFIG.maxContinuationRetries, - errorKind: classification.kind, - transportCode: classification.transportCode, - }); - } - - const contextOverflow = getContextLengthExceededInfo(error); - if (contextOverflow.isExceeded) { - if (!exactRoute && !reactiveCompressionAttempted) { - reactiveCompressionAttempted = true; - const reactiveOriginalTokenCount = - contextOverflow.actualTokens ?? - contextOverflow.limitTokens ?? - cgConfig?.contextWindowSize ?? - DEFAULT_TOKEN_LIMIT; - debugLogger.warn( - 'Context length exceeded; attempting reactive compression.', - ); - try { - const reactiveInfo = await self.tryCompress( - prompt_id, - true, - params.config?.abortSignal, - { - originalTokenCountOverride: reactiveOriginalTokenCount, - precomputedEffectiveTokens: reactiveOriginalTokenCount, - requestGenerationConfig: params.config, - requestRouteKey, - trigger: 'auto', - }, - ); - - if ( - reactiveInfo.compressionStatus === - CompressionStatus.COMPRESSED - ) { - // No-op today: tryCompress's setHistory has already - // cleared the marker. Kept for uniformity with the - // other retry branches in case a future in-place - // tryCompress stops resetting it. - self.popPendingPartialAssistantTurn(); - - // Reactive compression replaces the committed user turn. - // Keep its one-shot notice in the rebuilt retry request. - const noticeText = manualPlanExitNoticeText; - if ( - noticeText && - !self.history.some((content) => - content.parts?.some((part) => - part.text?.includes(noticeText), - ), - ) - ) { - const lastContent = self.history.at(-1); - if (lastContent?.role === 'user') { - lastContent.parts = [ - ...(lastContent.parts ?? []), - { text: noticeText }, - ]; - } else { - self.history.push( - createUserContent([{ text: noticeText }]), - ); - } - } - requestContents = self.getRequestHistoryForRoute( - currentUserContent, - requestModalities, - ); - debugLogger.info( - `Reactive compression succeeded: ` + - `${reactiveInfo.originalTokenCount} -> ` + - `${reactiveInfo.newTokenCount} tokens.`, - ); - yield { - type: StreamEventType.COMPRESSED, - info: reactiveInfo, - }; - yield { type: StreamEventType.RETRY }; - // Compression rebuilt `requestContents` from scratch, so - // any continuation staged against the old contents is - // stale — and the RETRY above already told the UI to drop - // the delivered text. - resetTransportContinuation(); - suppressNextRetryEvent = true; - rearmQuietAcceptanceIfBudgetSpent(); - continue; - } - - debugLogger.warn( - `Reactive compression did not recover context overflow: ` + - `status=${reactiveInfo.compressionStatus}.`, - ); - if ( - isCompressionFailureStatus(reactiveInfo.compressionStatus) - ) { - // Reactive compression is force=true so tryCompress's - // failure branch did not increment the counter. Count it - // explicitly as one strike — a single transient error - // (network blip, model 5xx) should not permanently latch - // the breaker; only repeated reactive failures should. - // The only recovery path for a latched counter is a - // successful compaction (post-call reset at the COMPRESSED - // branch in tryCompress); hard-rescue forwards the counter - // as-is since force=true bypasses the breaker. - self.consecutiveFailures += 1; - if (self.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { - debugLogger.warn( - `[compaction] circuit breaker tripped after ${self.consecutiveFailures} consecutive failures (reactive overflow path); auto-compaction will NOOP on the cheap-gate until a successful force compaction resets the counter.`, - ); - } - } - } catch (compressionError) { - if ( - params.config?.abortSignal?.aborted || - isAbortError(compressionError) - ) { - throw compressionError; - } - debugLogger.warn( - 'Reactive compression failed.', - compressionError, - ); - } - } else { - debugLogger.warn( - 'Reactive compression already attempted; ' + - 'propagating the context overflow error to caller.', - ); - } - break; - } - - if ( - error instanceof InvalidStreamError && - (error.type === 'NO_TOOL_RESULT_PROGRESS_MAX_TOKENS' || - (error.type === 'NO_RESPONSE_TEXT' && - lastFinishReason === FinishReason.MAX_TOKENS)) && - !maxTokensEscalated && - !hasUserMaxTokensOverride && - shouldEscalateMaxOutputTokens - ) { - lastError = null; - lastFinishReason = FinishReason.MAX_TOKENS; - break; - } - - // Invalid stream responses use INVALID_STREAM_RETRY_CONFIG, which - // is independent from HTTP retries handled by retryWithBackoff. - const isInvalidStreamError = error instanceof InvalidStreamError; - const maxInvalidStreamRetries = - isInvalidStreamError && error.type === 'PROTOCOL_TAG_LEAK' - ? INVALID_STREAM_RETRY_CONFIG.protocolTagLeakMaxRetries - : INVALID_STREAM_RETRY_CONFIG.transientMaxRetries; - const invalidStreamRetryCount = - isInvalidStreamError && error.type === 'PROTOCOL_TAG_LEAK' - ? protocolTagLeakRetryCount - : transientInvalidStreamRetryCount; - if ( - isInvalidStreamError && - invalidStreamRetryCount < maxInvalidStreamRetries - ) { - self.popPendingPartialAssistantTurn(); - const nextInvalidStreamRetryCount = invalidStreamRetryCount + 1; - if (error.type === 'PROTOCOL_TAG_LEAK') { - protocolTagLeakRetryCount = nextInvalidStreamRetryCount; - } else { - transientInvalidStreamRetryCount = nextInvalidStreamRetryCount; - } - // The armed attempt itself can fail with an invalid-stream - // error and be rescheduled here; rearm so the acceptance is - // not lost across error types (a tag-leak retry scheduled - // after the transient budget is spent must still land armed). - // Transient-keyed, so a tag-leak-only exhaustion never arms - // prematurely (#9026, #7039 retry-first). - rearmQuietAcceptanceIfBudgetSpent(); - const delayMs = - INVALID_STREAM_RETRY_CONFIG.initialDelayMs * - nextInvalidStreamRetryCount; - debugLogger.warn( - `Invalid stream [${(error as InvalidStreamError).type}] ` + - `(retry ${nextInvalidStreamRetryCount}/${maxInvalidStreamRetries}). ` + - `Waiting ${delayMs / 1000}s before retrying...`, - ); - logContentRetry( - self.config, - new ContentRetryEvent( - nextInvalidStreamRetryCount - 1, - (error as InvalidStreamError).type, - delayMs, - model, - ), - ); - yield { type: StreamEventType.RETRY }; - await delay(delayMs, params.config?.abortSignal).promise; - continue; - } - break; - } - } - - // Max output tokens handling: if the retry loop succeeded but hit - // MAX_TOKENS, retry once at an escalated output limit only when that - // would raise the effective initial limit. The escalation target is - // OUTPUT_TOKEN_CEILING, routed through the same window clamp as the - // initial request so the retry itself cannot overflow the window. - // When the initial limit is already at the ceiling (for example the - // clamp was binding), skip the no-op escalation call but still run - // continuation recovery on the partial response. These follow-up - // streams still need the same InvalidStreamError retry guard as the - // main send loop; otherwise a leaked protocol-tag turn would bypass - // the primary rollback/retry path entirely. - const rollbackRecoveryAttempt = () => { - // Pop the partial `model[fc]` FIRST (if processStreamResponse - // pushed one before re-throwing), THEN the recovery user turn. - // Reversed order would strand `OUTPUT_RECOVERY_MESSAGE` as a real - // user turn. Index-checked pop mirrors `popPartialIfPushed` - // above — see the design note above - // `ORPHAN_TOOL_USE_REPAIR_REASON` for the wedge mechanism and - // the partial-push marker lifecycle. - const expectedIdx = self.pendingPartialAssistantTurnIndex; - const lastIdx = self.history.length - 1; - if ( - expectedIdx !== null && - self.history.length > 0 && - self.history[lastIdx]?.role === 'model' - ) { - if (expectedIdx !== lastIdx) { - debugLogger.warn( - `[RECOVERY_POP] Marker/last-index mismatch: ` + - `marker=${expectedIdx}, lastIdx=${lastIdx}, ` + - `historyLength=${self.history.length}. Popping ` + - `last entry as best-effort rollback — investigate ` + - `any history mutation between processStreamResponse's ` + - `partial push and this catch.`, - ); - } - self.history.pop(); - self.clearPendingPartialState(); - } - if ( - self.history.length > 0 && - self.history[self.history.length - 1].role === 'user' - ) { - self.history.pop(); - } - }; - type InvalidStreamRetryEvent = - | Extract - | Extract; - const streamWithInvalidStreamRetries = async function* ( - buildAttempt: () => { - requestContents: Content[]; - params: SendMessageParameters; - rollback: () => void; - }, - retryEvent: Extract = { - type: StreamEventType.RETRY, - }, - ): AsyncGenerator { - let transientRetryCount = 0; - let protocolTagLeakRetryCount = 0; - let acceptQuietToolResultCompletionOnNextAttempt = false; - for (;;) { - const attemptState = buildAttempt(); - try { - const acceptQuietToolResultCompletion = - acceptQuietToolResultCompletionOnNextAttempt; - acceptQuietToolResultCompletionOnNextAttempt = false; - const stream = await self.makeApiCallAndProcessStream( - model, - attemptState.requestContents, - attemptState.params, - prompt_id, - requestOverrides, - requestRouteKey, - turnGoalContext, - undefined, - acceptQuietToolResultCompletion, - ); - for await (const chunk of stream) { - yield { type: StreamEventType.CHUNK, value: chunk }; - } - return; - } catch (error) { - attemptState.rollback(); - if (!(error instanceof InvalidStreamError)) throw error; - - const maxContinuationRetries = - error.type === 'PROTOCOL_TAG_LEAK' - ? INVALID_STREAM_RETRY_CONFIG.protocolTagLeakMaxRetries - : INVALID_STREAM_RETRY_CONFIG.transientMaxRetries; - const continuationRetryCount = - error.type === 'PROTOCOL_TAG_LEAK' - ? protocolTagLeakRetryCount - : transientRetryCount; - if (continuationRetryCount >= maxContinuationRetries) { - throw error; - } - - const nextContinuationRetryCount = continuationRetryCount + 1; - if (error.type === 'PROTOCOL_TAG_LEAK') { - protocolTagLeakRetryCount = nextContinuationRetryCount; - } else { - transientRetryCount = nextContinuationRetryCount; - } - // Same arming rule as the main send loop (#9026): keyed to - // the transient bucket only (quiet completions surface as a - // transient-type error), so a tag-leak-only exhaustion does - // not arm prematurely (#7039 retry-first). - if ( - transientRetryCount >= - INVALID_STREAM_RETRY_CONFIG.transientMaxRetries - ) { - acceptQuietToolResultCompletionOnNextAttempt = true; - } - const delayMs = - INVALID_STREAM_RETRY_CONFIG.initialDelayMs * - nextContinuationRetryCount; - debugLogger.warn( - `Invalid stream [${error.type}] during output continuation ` + - `(retry ${nextContinuationRetryCount}/${maxContinuationRetries}). ` + - `Waiting ${delayMs / 1000}s before retrying...`, - ); - logContentRetry( - self.config, - new ContentRetryEvent( - nextContinuationRetryCount - 1, - error.type, - delayMs, - model, - ), - ); - yield retryEvent; - await delay(delayMs, attemptState.params.config?.abortSignal) - .promise; - } - } - }; - if ( - lastError === null && - lastFinishReason === FinishReason.MAX_TOKENS && - !maxTokensEscalated && - !hasUserMaxTokensOverride - ) { - maxTokensEscalated = true; - let recoveryFinishReason: string | undefined = lastFinishReason; - let recoveryParams: SendMessageParameters = params; - - if (shouldEscalateMaxOutputTokens) { - debugLogger.info( - `Output truncated at ${effectiveInitialMaxOutputTokens} tokens. ` + - `Escalating to ${escalatedLimit} tokens.`, - ); - // Remove partial model response from history - // (processStreamResponse already pushed it) - if ( - self.history.length > 0 && - self.history[self.history.length - 1].role === 'model' - ) { - self.history.pop(); - } - // Signal UI to discard partial output - yield { - type: StreamEventType.RETRY, - maxOutputTokensEscalated: escalatedLimit, - }; - // Retry with escalated max_tokens - const escalatedParams: SendMessageParameters = { - ...params, - config: { - ...params.config, - maxOutputTokens: escalatedLimit, - }, - }; - recoveryParams = escalatedParams; - recoveryFinishReason = undefined; - for await (const event of streamWithInvalidStreamRetries(() => ({ - requestContents, - params: escalatedParams, - rollback: () => self.popPendingPartialAssistantTurn(), - }))) { - if (event.type === StreamEventType.RETRY) { - yield event; - continue; - } - const fr = event.value.candidates?.[0]?.finishReason; - if (fr) recoveryFinishReason = fr; - yield event; - } - } else { - debugLogger.info( - `Output truncated at ${effectiveInitialMaxOutputTokens} tokens; ` + - `skipping no-op escalation to ${escalatedLimit} tokens and running recovery.`, - ); - } - - // Recovery: if the escalated response (or, when escalation is a - // no-op, the initial response) is still truncated, keep the partial - // response in history and inject a recovery message so the model can - // continue from where it left off. - let recoveryCount = 0; - let successfulRecoveries = 0; - while ( - recoveryFinishReason === FinishReason.MAX_TOKENS && - recoveryCount < MAX_OUTPUT_RECOVERY_ATTEMPTS - ) { - // Skip recovery when the truncated turn already contains a - // functionCall. Injecting a plain user message between a - // functionCall and its functionResponse produces an invalid API - // sequence that providers commonly reject. The existing layer-3 - // tool scheduler fallback handles these cases correctly. - const lastEntry = self.history[self.history.length - 1]; - const hasFunctionCall = - lastEntry?.role === 'model' && - lastEntry.parts?.some((p) => p.functionCall) === true; - if (hasFunctionCall) { - debugLogger.info( - 'Skipping recovery: truncated turn contains functionCall; ' + - 'deferring to tool scheduler fallback.', - ); - break; - } - - recoveryCount++; - debugLogger.info( - `Output still truncated after max_tokens handling. ` + - `Recovery attempt ${recoveryCount}/${MAX_OUTPUT_RECOVERY_ATTEMPTS}.`, - ); - // The partial model response is already in history - // (pushed by processStreamResponse). Push a recovery user - // message so the model sees its partial output and continues. - const recoveryUserContent = createUserContent([ - { text: buildOutputRecoveryMessage(lastEntry) }, - ]); - // Signal UI/turn to clear pending (incomplete) tool calls. - // isContinuation tells the UI to keep the text buffer so the - // model's continuation appends to the previous partial output. - yield { type: StreamEventType.RETRY, isContinuation: true }; - recoveryFinishReason = undefined; - - // Re-clamp maxOutputTokens for THIS iteration: the prompt has - // grown by the previous partial response, so the value clamped - // before the first send would overflow the window if reused - // (prompt + stale max_tokens > window). Two independent - // estimates, take the max: - // - Count-based: lastPromptTokenCount/lastOutputTokenCount are - // refreshed from each response's usage metadata — authoritative - // when fresh, but a session-level value: a response that OMITS - // usage mid-recovery leaves it frozen while history keeps - // growing (inconsistent usage reporting from self-hosted - // backends is an anticipated failure class here). - // - Fresh walk of the actual outgoing contents, padded like the - // first send: structurally reflects in-turn growth no matter - // what usage was reported, while the pad covers the - // system/tool overhead a history walk cannot see. - // The max is conservative in the safe direction only: near the - // margin the two roughly agree (walk + pad ≈ authoritative - // count), and whichever went stale or blind is overruled. - const recoveryImageTokenEstimate = resolveSlimmingConfig( - self.config.getChatCompression(), - ).imageTokenEstimate; - const countBasedRecoveryEstimate = - self.lastPromptTokenCount > 0 - ? estimatePromptTokens( - [], - recoveryUserContent, - self.lastPromptTokenCount, - self.lastOutputTokenCount, - recoveryImageTokenEstimate, - /* conservative= */ true, - ) - : 0; - self.history.push(recoveryUserContent); - const recoveryContents = self.getRequestHistoryForRoute( - currentUserContent, - requestModalities, - ); - self.history.pop(); - const walkRecoveryEstimate = - estimateContentTokens( - recoveryContents, - recoveryImageTokenEstimate, - ) + ESTIMATE_CLAMP_OVERHEAD_PAD; - const recoveryPromptEstimate = Math.max( - countBasedRecoveryEstimate, - walkRecoveryEstimate, - ); - // recoveryParams is always `params` or `escalatedParams`, both of - // which have maxOutputTokens set; the `?? outputCeiling` is a - // defensive fallback that never fires in practice. - const recoveryCeiling = - recoveryParams.config?.maxOutputTokens ?? outputCeiling; - const iterationParams: SendMessageParameters = { - ...recoveryParams, - config: { - ...recoveryParams.config, - maxOutputTokens: clampOutputTokensToWindow( - recoveryCeiling, - contextWindowForClamp, - recoveryPromptEstimate, - ), - }, - }; - - try { - for await (const event of streamWithInvalidStreamRetries( - () => { - self.history.push(recoveryUserContent); - return { - requestContents: self.getRequestHistoryForRoute( - currentUserContent, - requestModalities, - ), - params: iterationParams, - rollback: rollbackRecoveryAttempt, - }; - }, - { type: StreamEventType.RETRY, isContinuation: true }, - )) { - if (event.type === StreamEventType.RETRY) { - yield event; - continue; - } - const fr = event.value.candidates?.[0]?.finishReason; - if (fr) recoveryFinishReason = fr; - yield event; - } - // Iteration fully succeeded: both the user recovery turn and - // the model continuation turn are now in history and can be - // coalesced back into the preceding model entry after the loop. - successfulRecoveries++; - } catch (recoveryError) { - rollbackRecoveryAttempt(); - debugLogger.warn( - `Recovery attempt ${recoveryCount} failed: ${recoveryError}`, - ); - // Emit a synthetic finish-reason chunk so the UI gets a - // terminal signal (Finished event) instead of a partial - // response with no end marker. Uses STOP because partial - // chunks from prior successful iterations are already in - // the transcript and represent the user-visible response. - yield { - type: StreamEventType.CHUNK, - value: { - candidates: [ - { - content: { role: 'model', parts: [] }, - finishReason: FinishReason.STOP, - }, - ], - } as unknown as GenerateContentResponse, - }; - break; - } - } - - // Coalesce completed recovery pairs back into the preceding model - // turn so the OUTPUT_RECOVERY_MESSAGE control prompt does not - // persist as a synthetic user turn in durable history. The user - // never sent that message, and leaving it in history would bias - // later turns and pollute compression / replay / export. - if (successfulRecoveries > 0) { - self.coalesceRecoveryPairs(successfulRecoveries); - } - } - - if (lastError) { - if (lastError instanceof InvalidStreamError) { - const totalAttempts = totalInvalidStreamRetryCount() + 1; - logContentRetryFailure( - self.config, - new ContentRetryFailureEvent( - totalAttempts, - lastError.type, - model, - ), - ); - } - - // === Model fallback chain === - // When the primary model's retries exhaust on a capacity/availability - // error, try configured fallback models in sequence. Each fallback - // model gets its own fresh retry budget. - // - // Constraints: - // - Do NOT trigger fallback when persistent mode is active - // (QWEN_CODE_UNATTENDED_RETRY) — persistent mode retries the primary - // model indefinitely by design. - // - Maximum 3 fallback transitions (capped by config normalization). - // - Fallback is only for capacity/availability errors (429/503/529), - // not for auth/billing/client errors. - const fallbackModels = - exactRoute || options?.disableModelFallbacks - ? [] - : self.config.getModelFallbacks(); - - if ( - fallbackModels.length > 0 && - !isUnattendedMode() && - !streamYieldedAnyChunk - ) { - let currentErrorClassification = classifyRetryError(lastError, { - authType: cgConfig?.authType, - extraRetryErrorCodes, - }); - - if (isFallbackEligible(currentErrorClassification)) { - let fallbackSucceeded = false; - let fallbackIndex = 0; - let currentModel = model; - let currentResolvedModel = cgConfig?.model ?? model; - let fallbackStreamYieldedAnyChunk = false; - - for (const fallbackModelId of fallbackModels) { - // Skip fallback models that match the current/primary model - if ( - fallbackModelId === model || - fallbackModelId === currentModel - ) { - debugLogger.warn( - `[FALLBACK] Skipping fallback model "${fallbackModelId}": ` + - `same as current model.`, - ); - continue; - } - - // Resolve the fallback model's content generator - let fallbackGenerator: ContentGenerator; - let fallbackRetryAuthType: string | undefined; - let fallbackRetryErrorCodes: readonly number[] | undefined; - let resolvedFallbackModel: string; - let fallbackModalities: InputModalities | undefined; - try { - const resolved = await self.config - .getBaseLlmClient() - .resolveForModel(fallbackModelId, { failClosed: true }); - fallbackGenerator = resolved.contentGenerator; - fallbackRetryAuthType = resolved.retryAuthType; - fallbackRetryErrorCodes = resolved.retryErrorCodes; - resolvedFallbackModel = resolved.model; - fallbackModalities = - resolved.contentGeneratorConfig?.modalities; - } catch (resolveError) { - if (isAbortError(resolveError)) throw resolveError; - const resolveErrorMessage = - resolveError instanceof Error - ? resolveError.message - : String(resolveError); - debugLogger.warn( - `[FALLBACK] Failed to resolve fallback model ` + - `"${fallbackModelId}": ` + - `${resolveErrorMessage}. ` + - `Trying next fallback.`, - ); - continue; - } - - if (resolvedFallbackModel === currentResolvedModel) { - debugLogger.warn( - `[FALLBACK] Skipping fallback model "${fallbackModelId}": ` + - `resolved model "${resolvedFallbackModel}" matches ` + - `the current model.`, - ); - continue; - } - fallbackIndex++; - - debugLogger.warn( - `[FALLBACK] Model "${currentModel}" exhausted retries ` + - `(reason: ${currentErrorClassification.reason}, ` + - `status: ${currentErrorClassification.statusCode ?? 'unknown'}). ` + - `Switching to fallback model "${fallbackModelId}" ` + - `(${fallbackIndex}/${fallbackModels.length}).`, - ); - - // Emit fallback event so the UI can notify the user - yield { - type: StreamEventType.MODEL_FALLBACK, - info: { - fromModel: currentModel, - toModel: resolvedFallbackModel, - statusCode: currentErrorClassification.statusCode, - fallbackIndex, - }, - }; - - // Remove the partial assistant turn from history before the - // fallback model starts producing its own response. - self.popPendingPartialAssistantTurn(); - - // Run the fallback model through the existing API-call wiring. - let currentFallbackYieldedAnyChunk = false; - try { - const fallbackRequestContents = - self.getRequestHistoryForRoute( - currentUserContent, - fallbackModalities ?? {}, - ); - // Stamp the fallback-served counts under the REQUEST route - // key: a fallback serves on behalf of the same session - // request (the session model never changes), and the - // session-token-limit gate in Client reads the count keyed - // by the request route. Attributing the count to the - // fallback's own route would make every later gate read - // invalidate it, silently disabling the limit for any - // session ever served through fallback (#9454). - for await (const event of self.makeFallbackStream( - resolvedFallbackModel, - fallbackRequestContents, - params, - prompt_id, - fallbackGenerator, - fallbackRetryAuthType, - fallbackRetryErrorCodes, - requestRouteKey, - turnGoalContext, - )) { - const emittedUserVisibleOutput = - event.type !== StreamEventType.CHUNK || - hasCandidateOutput(event.value); - if (emittedUserVisibleOutput) { - currentFallbackYieldedAnyChunk = true; - fallbackStreamYieldedAnyChunk = true; - } - yield event; - } - - // Fallback succeeded - lastError = null; - fallbackSucceeded = true; - debugLogger.info( - `[FALLBACK] Successfully completed request with ` + - `fallback model "${resolvedFallbackModel}".`, - ); - return; - } catch (fallbackError) { - if (isAbortError(fallbackError)) throw fallbackError; - lastError = fallbackError; - - if (currentFallbackYieldedAnyChunk) { - self.popPendingPartialAssistantTurn(); - debugLogger.warn( - `[FALLBACK] Fallback model "${resolvedFallbackModel}" ` + - `failed after emitting output. Popped the partial ` + - `assistant turn and stopped the fallback chain to ` + - `avoid duplicating user-visible output.`, - ); - break; - } - - // Classify the fallback error to decide whether to continue - // to the next fallback or give up - const fallbackClassification = classifyRetryError( - fallbackError, - { - authType: fallbackRetryAuthType, - extraRetryErrorCodes: fallbackRetryErrorCodes, - }, - ); - - const canTryNextFallback = isFallbackEligible( - fallbackClassification, - ); - debugLogger.warn( - `[FALLBACK] Fallback model "${resolvedFallbackModel}" also ` + - `failed (reason: ${fallbackClassification.reason}, ` + - `status: ${fallbackClassification.statusCode ?? 'unknown'}). ` + - `${canTryNextFallback ? 'Checking remaining fallbacks.' : 'Stopping fallback chain.'}`, - ); - - currentModel = resolvedFallbackModel; - currentResolvedModel = resolvedFallbackModel; - currentErrorClassification = fallbackClassification; - - // Only continue to next fallback if this error is also - // fallback-eligible. Auth/client errors should fail immediately. - if (!canTryNextFallback) { - debugLogger.warn( - `[FALLBACK] Error from "${resolvedFallbackModel}" is not ` + - `fallback-eligible (${fallbackClassification.reason}). ` + - `Stopping fallback chain.`, - ); - break; - } - } - } - - if (!fallbackSucceeded) { - if (!fallbackStreamYieldedAnyChunk) { - self.popPendingPartialAssistantTurn(); - } - debugLogger.warn( - '[FALLBACK] Fallback chain exhausted without success. ' + - 'Throwing last error.', - ); - } - } else { - debugLogger.warn( - '[FALLBACK] Fallback chain skipped: primary error is not ' + - 'fallback-eligible ' + - `(reason: ${currentErrorClassification.reason}, ` + - `diagnosis: ${currentErrorClassification.diagnosis}, ` + - `status: ${currentErrorClassification.statusCode ?? 'unknown'}, ` + - `error: ${lastError instanceof Error ? lastError.message : String(lastError)}).`, - ); - } - } else if ( - fallbackModels.length > 0 && - !isUnattendedMode() && - streamYieldedAnyChunk - ) { - debugLogger.warn( - '[FALLBACK] Fallback chain skipped because the primary model ' + - 'already emitted user-visible output.', - ); - } - - if (lastError) { - throw lastError; - } - } - } finally { - sleepInhibitorHandle.release(); - streamDoneResolver!(); - // Flush any deferred partial-tool_use record. Covers both the - // post-retry-loop unretryable break AND the max-tokens - // escalation throw (the escalated processStreamResponse can - // set a new record that escapes the retry-loop catch). - // Recording-service errors are logged at error level (sustained - // failure = monitoring signal) and swallowed — propagating - // would mask the real send outcome. - if (self.pendingPartialAssistantRecord) { - try { - self.chatRecordingService?.recordAssistantTurn( - self.pendingPartialAssistantRecord, - ); - } catch (recordErr) { - debugLogger.error( - '[PARTIAL_FLUSH] Failed to persist deferred JSONL record: ' + - (recordErr instanceof Error - ? recordErr.message - : String(recordErr)), - ); - } - self.clearPendingPartialState(); - } - } - })(); - } - - /** - * Makes an API call with retry logic and returns the processed stream. - * - * When called without `overrides`, uses the session's primary content - * generator and provider config (the common path for the main model). - * Pass `overrides` to run against a different content generator — used - * by the fallback chain to call alternative models without duplicating - * the retry wiring. - */ - private async makeApiCallAndProcessStream( - model: string, - requestContents: Content[], - params: SendMessageParameters, - prompt_id: string, - overrides?: { - contentGenerator: ContentGenerator; - retryAuthType?: string; - retryErrorCodes?: readonly number[]; - }, - routeKey = this.currentRouteKey(), - goalContext?: GoalTurnPermit, - transportContinuationPrefix?: string, - acceptQuietToolResultCompletion = false, - ): Promise> { - const generator = - overrides?.contentGenerator ?? this.config.getContentGenerator(); - const apiCall = () => - generator.generateContentStream( - { - model, - contents: requestContents, - config: { ...this.generationConfig, ...params.config }, - }, - prompt_id, - ); - const cgConfig = this.config.getContentGeneratorConfig(); - const authType = overrides?.retryAuthType ?? cgConfig?.authType; - const extraRetryErrorCodes = - overrides?.retryErrorCodes ?? cgConfig?.retryErrorCodes; - // Fallback models never enter persistent retry mode — persistent mode - // is the caller's explicit opt-in for the primary model only. - const persistentMode = overrides ? false : isUnattendedMode(); - const streamResponse = await retryWithBackoff(apiCall, { - shouldRetryOnError: (error: unknown) => { - if (error instanceof Error) { - if (isSchemaDepthError(error.message)) return false; - if (isInvalidArgumentError(error.message)) return false; - } - - const status = getErrorStatus(error); - if (status === 400) return false; - if (status === 429) return true; - if (status && status >= 500 && status < 600) return true; - - // Honor provider-specific rate-limit codes (e.g. DashScope) so a custom - // predicate does not silently drop them — the default path checks these - // via defaultShouldRetry, but a custom shouldRetryOnError bypasses it. - if (isRateLimitError(error, extraRetryErrorCodes)) return true; - - // Transient network errors (ECONNRESET, ETIMEDOUT, etc.) carry no HTTP - // status and would otherwise fall through every predicate above. - if ( - classifyRetryError(error, { extraRetryErrorCodes }).kind === - 'transport' - ) { - return true; - } - - return false; - }, - authType, - extraRetryErrorCodes, - persistentMode, - signal: params.config?.abortSignal, - ...(persistentMode - ? { - heartbeatFn: (info: HeartbeatInfo) => { - process.stderr.write( - `[qwen-code] Waiting for API capacity... attempt ${info.attempt}, retry in ${Math.ceil(info.remainingMs / 1000)}s\n`, - ); - }, - } - : {}), - onRetry: (info) => { - logApiRetry( - this.config, - new ApiRetryEvent({ - model, - promptId: prompt_id, - attemptNumber: info.attempt, - error: info.error, - statusCode: info.errorStatus, - retryDelayMs: info.delayMs, - subagentName: subagentNameContext.getStore(), - }), - ); - }, - }); - - return this.processStreamResponse( - model, - rejectDegradedPlaceholderResponse(streamResponse), - routeKey, - goalContext, - transportContinuationPrefix, - acceptQuietToolResultCompletion, - ); - } - - private async *makeFallbackStream( - model: string, - requestContents: Content[], - params: SendMessageParameters, - prompt_id: string, - contentGenerator: ContentGenerator, - retryAuthType?: string, - retryErrorCodes?: readonly number[], - routeKey?: string, - goalContext?: GoalTurnPermit, - ): AsyncGenerator { - const stream = await this.makeApiCallAndProcessStream( - model, - requestContents, - params, - prompt_id, - { contentGenerator, retryAuthType, retryErrorCodes }, - routeKey, - goalContext, - ); - - for await (const chunk of stream) { - yield { type: StreamEventType.CHUNK, value: chunk }; - } - } - - /** - * Returns the chat history. - * - * @remarks - * The history is a list of contents alternating between user and model. - * - * There are two types of history: - * - The `curated history` contains only the valid turns between user and - * model, which will be included in the subsequent requests sent to the model. - * - The `comprehensive history` contains all turns, including invalid or - * empty model outputs, providing a complete record of the history. - * - * The history is updated after receiving the response from the model, - * for streaming response, it means receiving the last chunk of the response. - * - * The `comprehensive history` is returned by default. To get the `curated - * history`, set the `curated` parameter to `true`. - * - * @param curated - whether to return the curated history or the comprehensive - * history. - * @return History contents alternating between user and model for the entire - * chat session. - */ - getHistory(curated: boolean = false): Content[] { - const history = curated - ? extractCuratedHistory(this.history) - : this.history; - // Deep copy the history to avoid mutating the history outside of the - // chat session. - return structuredClone(history); - } - - /** - * Returns a deep-copied tail of the chat history. This avoids cloning the - * entire session when callers only need recent context. - */ - getHistoryTail(count: number, curated: boolean = false): Content[] { - if (count <= 0) return []; - const history = curated - ? extractCuratedHistory(this.history) - : this.history; - return structuredClone(history.slice(-count)); - } - - /** - * Copies history containers, Part objects, and nested functionResponse parts - * without cloning large leaf payloads. Consumers must not mutate leaf - * payload objects. - */ - getHistoryShallow(curated: boolean = false): Content[] { - const history = curated - ? extractCuratedHistory(this.history) - : this.history; - return history.map(copyContentContainer); - } - - getHistoryForForkWindow(): Content[] { - const history = this.history.slice(getStartupContextLength(this.history)); - return extractCuratedHistory(history).map(copyContentContainer); - } - - /** - * Shallow tail variant for hot paths that only need recent history. - */ - getHistoryTailShallow(count: number, curated: boolean = false): Content[] { - if (count <= 0) return []; - const history = curated - ? extractCuratedHistory(this.history) - : this.history; - return history.slice(-count).map(copyContentContainer); - } - - /** - * Returns a defensive copy of the last raw history entry without cloning the - * full conversation. This avoids O(history) cloning, though cloning the last - * entry is still proportional to that entry's own size. - */ - getLastHistoryEntry(): Content | undefined { - return this.getHistoryTail(1)[0]; - } - - /** - * Returns the last raw history entry for read-only checks. Callers must not - * mutate the returned object. - */ - peekLastHistoryEntry(): Content | undefined { - return this.history.at(-1); - } - - /** - * Returns concatenated text from the last model entry without cloning the - * full history. Used by stop hooks, where only the latest assistant text is - * needed. - */ - getLastModelMessageText(): string | undefined { - for (let i = this.history.length - 1; i >= 0; i--) { - const message = this.history[i]; - if (message?.role !== 'model') continue; - const text = - message.parts - ?.filter( - (part): part is { text: string } => - typeof part.text === 'string' && !part.thought, - ) - .map((part) => part.text) - .join('') ?? ''; - return text || undefined; - } - return undefined; - } - - /** - * Returns the number of entries in the raw chat history. O(1) and - * does not clone — use this when you only need the count and would - * otherwise pay the {@link getHistory} `structuredClone` cost. - */ - getHistoryLength(): number { - return this.history.length; - } - - /** - * Monotonic count of user-content pushes that survived into history (see the - * field doc). Snapshot it before a send and compare after to tell whether the - * send actually pushed the user content — robust to auto-compression, which - * changes history length without touching this counter. - */ - getUserContentPushCount(): number { - return this.userContentPushCount; - } - - /** - * Set of `functionResponse.id` strings in user turns. Walk-only, - * no clone — `useGeminiStream.handleCompletedTools` calls this per - * tool-completion batch, so {@link getHistory}'s `structuredClone` - * would stall the UI on long sessions. - */ - getHistoryFunctionResponseIds(): Set { - const ids = new Set(); - for (const entry of this.history) { - if (entry.role !== 'user') continue; - for (const part of entry.parts ?? []) { - const id = part.functionResponse?.id; - if (id) ids.add(id); - } - } - return ids; - } - - /** - * Map of handled tool-call id → (name, args) fingerprint for duplicate - * provider-id replay detection: model-turn `functionCall`s whose id has a - * matching user-turn `functionResponse`. Walk-only, no clone, same - * rationale as {@link getHistoryFunctionResponseIds}; fingerprints of - * large args are cached per part object (see getFunctionCallFingerprint). - */ - getHistoryToolCallFingerprints(): Map { - const fingerprintsById = new Map(); - const respondedIds = new Set(); - for (const entry of this.history) { - if (entry.role === 'user') { - for (const part of entry.parts ?? []) { - const id = part.functionResponse?.id; - if (id) respondedIds.add(id); - } - continue; - } - for (const part of entry.parts ?? []) { - const functionCall = part.functionCall; - if (functionCall?.id && !fingerprintsById.has(functionCall.id)) { - fingerprintsById.set( - functionCall.id, - getFunctionCallFingerprint(functionCall), - ); - } - } - } - const handled = new Map(); - for (const id of respondedIds) { - const fingerprint = fingerprintsById.get(id); - if (fingerprint !== undefined) handled.set(id, fingerprint); - } - return handled; - } - - /** - * Clears the chat history. - */ - clearHistory(): void { - this.history = []; - // Any pending partial-push state points into the now-empty history; - // resetting prevents `popPartialIfPushed` from splicing whatever - // shows up at that index in a future send (defense-in-depth — the - // helper also bounds-checks, but a stale marker that happens to - // line up with a real model turn could otherwise pop the wrong - // entry). The deferred-record stash is dropped for the same reason: - // a later flush would append a turn that doesn't match the (now- - // empty) live history. - this.clearPendingPartialState(); - } - - /** - * Adds a new entry to the chat history. - */ - addHistory(content: Content): void { - this.history.push(content); - // addHistory only runs between sends, so the partial-push marker - // should already be cleared. If it is not, a new caller is - // violating that invariant — surface it at error level so the - // offending stack is visible. See the design note above - // `ORPHAN_TOOL_USE_REPAIR_REASON` for the marker lifecycle. - if ( - this.pendingPartialAssistantTurnIndex !== null || - this.pendingPartialAssistantRecord !== null - ) { - debugLogger.error( - '[INVARIANT_VIOLATION] addHistory called while a partial-push ' + - 'marker is active — clearing it.', - ); - } - this.clearPendingPartialState(); - } - - /** - * Replaces the `plan` argument of an `exit_plan_mode` `functionCall` in - * history with a short reference, keeping every other part and argument - * intact. - * - * The full plan text a model submits to `exit_plan_mode` stays in history - * as its own tool-call arguments; on long conversations models - * occasionally regurgitate chunks of that blob in later responses - * (#6237). Once the plan is approved it is persisted to disk by - * `Config.savePlan`, so the in-context copy can be swapped for a pointer - * without losing information. Rejected plans are left untouched — the - * model needs the text to revise them. - * - * When `expectedPlan` is provided the rewrite additionally requires the - * in-history plan to equal it byte-for-byte. Callers pass the on-disk - * plan-file content here so the pointer can never claim a save that - * failed (`savePlanBestEffort` swallows filesystem errors) or reference - * a file that holds a different plan. - * - * The entry is replaced immutably at the same index; the partial-push - * markers compare by index and role, so this cannot desync them. - * - * @returns true when a matching functionCall was found and rewritten. - */ - redactApprovedPlanFromHistory( - callId: string, - replacement: string, - expectedPlan?: string, - ): boolean { - for (let i = this.history.length - 1; i >= 0; i--) { - const entry = this.history[i]; - if (entry?.role !== 'model' || !entry.parts) continue; - const partIdx = entry.parts.findIndex( - (part) => - part.functionCall?.id === callId && - canonicalPlanToolName(part.functionCall.name) === - ToolNames.EXIT_PLAN_MODE, - ); - if (partIdx === -1) continue; - const part = entry.parts[partIdx]!; - const functionCall = part.functionCall!; - const plan = (functionCall.args ?? {})['plan']; - if (typeof plan !== 'string') { - return false; - } - if (expectedPlan !== undefined && plan !== expectedPlan) { - return false; - } - const newParts = [...entry.parts]; - newParts[partIdx] = { - ...part, - functionCall: { - ...functionCall, - args: { ...functionCall.args, plan: replacement }, - }, - }; - this.history[i] = { ...entry, parts: newParts }; - return true; - } - return false; - } - - /** - * Read-side counterpart of {@link redactApprovedPlanFromHistory}: the - * chat-recording JSONL captured the assistant turn (with the full plan - * argument) before the tool ran, so a `--resume` / `--continue` reload - * re-feeds the plan text the in-session redaction already removed - * (#6237). Every wholesale history load re-applies the redaction to - * approved `exit_plan_mode` calls. - * - * Only calls whose in-history plan matches the current on-disk plan file - * are rewritten — same never-lie rule as the write side. With several - * approved plans in one session the file holds only the last one, so - * earlier calls rehydrate unredacted; safe, just not minimal. - */ - private redactApprovedPlansFromLoadedHistory(): void { - const hasPlanCall = this.history.some((entry) => - entry?.parts?.some( - (part) => - canonicalPlanToolName(part.functionCall?.name) === - ToolNames.EXIT_PLAN_MODE, - ), - ); - if (!hasPlanCall) return; - let planPath: string; - let savedPlan: string; - try { - planPath = this.config.getPlanFilePath(); - savedPlan = fs.readFileSync(planPath, 'utf-8'); - } catch (err) { - // No plan file (never saved, or save failed): leave history alone — - // never swap plan text for a pointer to a file that is not there. - // Logged (unlike a bare swallow) so a --resume that silently skips - // the redaction is traceable under DEBUG. - debugLogger.debug( - `Skipping load-side plan redaction, plan file unavailable: ${err}`, - ); - return; - } - const redacted = redactApprovedPlansInHistory( - this.history, - savedPlan, - planPath, - ); - if (redacted) { - this.history = redacted; - } else { - // hasPlanCall was true, so a null here means every exit_plan_mode - // call was skipped (unapproved, id-less, or plan text differing - // from the saved file) — trace it for "plan still in history" - // triage, mirroring the write side. - debugLogger.debug( - `Load-side plan redaction left history unchanged: no approved ` + - `exit_plan_mode call matches the plan file at ${planPath}.`, - ); - } - } - - setHistory(history: Content[]): void { - this.history = history; - // History replacement (compression, /clear, --resume reload) wipes - // the index basis the partial-push marker was captured against. The - // marker MUST be cleared — otherwise `popPartialIfPushed` could find - // a model turn at the stale index in the replacement history and - // splice an entry that has nothing to do with the original partial - // push, corrupting the conversation. Drop the paired deferred-record - // stash too: its referent (the model turn at the old index) is gone. - this.clearPendingPartialState(); - this.redactApprovedPlansFromLoadedHistory(); - } - - truncateHistory(keepCount: number): void { - this.history = this.history.slice(0, keepCount); - // Truncation can drop the entry the partial-push marker points at, - // or leave it valid but shift the meaning of nearby indices. Reset - // both fields rather than try to fix them up — they're per-send and - // ephemeral, so losing them across a truncate is safe (the - // sendMessageStream that pushed them has already finished or will - // start fresh on the next call). - this.clearPendingPartialState(); - } - - stripThoughtsFromHistory(): void { - this.history = this.history - .map(stripThoughtPartsFromContent) - .filter((content): content is Content => content !== null); - // Filter+map replaces `this.history` with a new array, so any pending - // partial-push marker is now indexed against an array that no longer - // exists. Clear it for the same reason setHistory does — and drop - // the paired deferred-record stash so a later flush can't land a - // turn that doesn't exist in live history. - this.clearPendingPartialState(); - } - - /** - * Pop orphaned trailing user entries from chat history. - * In a valid conversation the last entry is always a model response; - * any trailing user entries are leftovers from a request that failed. - */ - stripOrphanedUserEntriesFromHistory(): Content[] { - const strippedEntries: Content[] = []; - while ( - this.history.length > 0 && - this.history[this.history.length - 1]!.role === 'user' - ) { - // Never pop a *pure* system-reminder user entry. These are structural, - // not orphaned turns: the startup-context prelude (history[0]) and - // mid-history MCP added-tool reminders injected by - // drainPendingAddedMcpToolsReminder. Popping the latter would lose the - // announcement permanently — pendingAddedMcpTools is already cleared and - // the tool name is already in announcedDeferredToolNames, so - // queueAddedMcpToolsReminder won't re-queue it. - // - // Must check EVERY part, not just parts[0]: a failed user turn in plan - // mode (or with subagent/memory reminders) is recorded as one Content - // whose parts are […, actual prompt]. Matching parts[0] - // alone would treat that as structural and preserve the user's prompt - // text, which then leaks into the next turn via appendCuratedContent. - const lastEntry = this.history[this.history.length - 1]; - if (lastEntry && isSystemReminderContent(lastEntry)) { - break; - } - strippedEntries.unshift(this.history.pop()!); - } - // Today this is safe even without the reset — only trailing user - // entries are popped, which can't shift the index of an earlier - // `model` partial. But every other history-mutation method now - // clears the partial-push state in lockstep - // (clearHistory/addHistory/setHistory/truncateHistory/ - // stripThoughtsFromHistory), so omitting it here would be a silent - // exception to the uniform invariant: a future caller invoking - // this method between the deferred JSONL flush and the next - // `sendMessageStream` would otherwise leave a stale marker that - // happens to line up with whatever model entry is at that index - // in the meanwhile. - this.clearPendingPartialState(); - return strippedEntries; - } - - /** - * Instance wrapper around the free-function {@link repairOrphanedToolUseTurns}. - * See the canonical note above `ORPHAN_TOOL_USE_REPAIR_REASON`. - */ - repairOrphanedToolUseTurns( - reason?: string, - options?: RepairOrphanedToolUseOptions, - ): { - injected: Array<{ callId: string; name: string }>; - droppedDuplicates: Array<{ callId: string; name: string }>; - } { - return repairOrphanedToolUseTurns(this.history, reason, options); - } - - setTools(tools: Tool[]): void { - this.generationConfig.tools = tools; - } - - /** Returns a shallow copy of the current generation config (for cache param snapshots). */ - getGenerationConfig(): GenerateContentConfig { - return { ...this.generationConfig }; - } - - async maybeIncludeSchemaDepthContext(error: StructuredError): Promise { - // Check for potentially problematic cyclic tools with cyclic schemas - // and include a recommendation to remove potentially problematic tools. - if ( - isSchemaDepthError(error.message) || - isInvalidArgumentError(error.message) - ) { - const toolRegistry = this.config.getToolRegistry(); - await toolRegistry.warmAll(); - const tools = toolRegistry.getAllTools(); - const cyclicSchemaTools: string[] = []; - for (const tool of tools) { - if ( - (tool.schema.parametersJsonSchema && - hasCycleInSchema(tool.schema.parametersJsonSchema)) || - (tool.schema.parameters && hasCycleInSchema(tool.schema.parameters)) - ) { - cyclicSchemaTools.push(tool.displayName); - } - } - if (cyclicSchemaTools.length > 0) { - const extraDetails = - `\n\nThis error was probably caused by cyclic schema references in one of the following tools, try disabling them with excludeTools:\n\n - ` + - cyclicSchemaTools.join(`\n - `) + - `\n`; - error.message += extraDetails; - } - } - } - - /** - * @param transportContinuationPrefix - Text a previous attempt already - * delivered before a socket cut, which this attempt was asked to resume - * from (issue #7832). On success it is folded into the response parts - * before either durable write, so the JSONL transcript and in-memory - * history carry the same merged turn (issue #8094). Undefined on every - * non-continuation send. - */ - private async *processStreamResponse( - model: string, - streamResponse: AsyncGenerator, - routeKey: string, - goalContext?: GoalTurnPermit, - transportContinuationPrefix?: string, - acceptQuietToolResultCompletion = false, - ): AsyncGenerator { - // Collect ALL parts from the model response (including thoughts for recording) - const allModelParts: Part[] = []; - const usedToolCallIds = collectToolCallIdsFromHistory(this.history); - const rawToolCallIdsInCurrentTurn = new Set(); - const reservedToolCallIds = new Map(); - let usageMetadata: GenerateContentResponseUsageMetadata | undefined; - let coercedUsage: - | { - promptTokenCount: number; - totalTokenCount: number; - candidatesTokenCount: number; - cachedContentTokenCount: number; - thoughtsTokenCount: number; - } - | undefined; - - let hasToolCall = false; - let hasFinishReason = false; - const protocolTagDetector = new LeadingProtocolTagLeakDetector(); - let pendingProtocolParts: Part[] = []; - const takePendingProtocolParts = (): Part[] => { - const parts = pendingProtocolParts; - pendingProtocolParts = []; - const released: Part[] = []; - for (const part of parts) { - const previous = released.at(-1); - if ( - previous && - isValidNonThoughtTextPart(previous) && - isValidNonThoughtTextPart(part) - ) { - previous.text! += part.text!; - } else { - released.push(isValidNonThoughtTextPart(part) ? { ...part } : part); - } - } - return released; - }; - let protocolTextWasSuppressed = false; - const currentUserTurn = this.history[this.history.length - 1]; - const isToolResultContinuation = - currentUserTurn?.role === 'user' && - currentUserTurn.parts?.some((part) => part.functionResponse) === true; - let deferredFinishReason: FinishReason | undefined; - // Captured if the upstream stream throws mid-iteration (typical on weak - // networks: SSE drops between `content_block_stop` of a tool_use and the - // terminal `message_stop`). We still build / record / push a partial - // assistant turn below before re-throwing — see the dedicated branch in - // the post-loop block for why this is needed to keep tool_use/tool_result - // pairing intact across the failure. - let streamError: unknown = null; - - try { - for await (const chunk of streamResponse) { - const preparations = getToolCallPreparations(chunk); - if (preparations.length > 0) { - setToolCallPreparations( - chunk, - preparations.map((preparation) => ({ - ...preparation, - callId: reserveModelToolCallId( - preparation.callId, - usedToolCallIds, - reservedToolCallIds, - ), - })), - ); - } - - // Use ||= to avoid later usage-only chunks (no candidates) overwriting - // a finishReason that was already seen in an earlier chunk. - hasFinishReason ||= - chunk?.candidates?.some((candidate) => candidate.finishReason) ?? - false; - - if (isValidResponse(chunk)) { - const candidate = chunk.candidates?.[0]; - let content = candidate?.content; - if (candidate?.finishReason && !content?.parts) { - protocolTagDetector.finish(); - if (protocolTagDetector.leaked) { - pendingProtocolParts = []; - } else { - const parts = takePendingProtocolParts(); - if (parts.length > 0) { - content = { - ...content, - role: content?.role ?? 'model', - parts, - }; - candidate.content = content; - } - } - } - if (content?.parts) { - const outputParts: Part[] = []; - for (const part of content.parts) { - if ( - isToolResultContinuation && - !part.thought && - part.text?.trim() === GEMINI_EMPTY_CONTENT_PLACEHOLDER - ) { - continue; - } - if (typeof part.text !== 'string' || part.thought) { - if ( - pendingProtocolParts.length > 0 || - protocolTagDetector.leaked - ) { - pendingProtocolParts.push(part); - } else { - outputParts.push(part); - } - continue; - } - const text = protocolTagDetector.accept(part.text); - if (text) { - if (pendingProtocolParts.length > 0) { - outputParts.push(...takePendingProtocolParts(), part); - } else { - outputParts.push({ ...part, text }); - } - continue; - } - pendingProtocolParts.push(...outputParts.splice(0), part); - protocolTextWasSuppressed ||= part.text.length > 0; - } - content.parts = outputParts; - if (candidate?.finishReason) { - protocolTagDetector.finish(); - if (protocolTagDetector.leaked) { - pendingProtocolParts = []; - } else { - content.parts.push(...takePendingProtocolParts()); - } - } - content.parts = normalizeModelToolCallIds( - content.parts, - usedToolCallIds, - rawToolCallIdsInCurrentTurn, - reservedToolCallIds, - ); - syncFunctionCallsField(chunk, content.parts); - - if (content.parts.some((part) => part.functionCall)) { - hasToolCall = true; - } - - // Collect all parts for recording - allModelParts.push(...content.parts); - } - } - - // Collect token usage for consolidated recording - if (chunk.usageMetadata) { - usageMetadata = chunk.usageMetadata; - // Context usage tracks prompt size; output isn't in history yet. - // Coerce hostile-provider values (NaN / Infinity / negative) to 0 - // so the compaction gate arithmetic stays well-defined; see - // `coerceUsageCount` for the failure modes this guards against. - const hasUsablePromptTokenCount = - typeof usageMetadata.promptTokenCount === 'number' && - Number.isFinite(usageMetadata.promptTokenCount) && - usageMetadata.promptTokenCount >= 0; - const hasUsableTotalTokenCount = - typeof usageMetadata.totalTokenCount === 'number' && - Number.isFinite(usageMetadata.totalTokenCount) && - usageMetadata.totalTokenCount >= 0; - const promptTokenCount = coerceUsageCount( - usageMetadata.promptTokenCount, - 'promptTokenCount', - ); - const totalTokenCount = coerceUsageCount( - usageMetadata.totalTokenCount, - 'totalTokenCount', - ); - const candidatesTokenCount = coerceUsageCount( - usageMetadata.candidatesTokenCount, - 'candidatesTokenCount', - ); - const cachedContentTokenCount = coerceUsageCount( - usageMetadata.cachedContentTokenCount, - 'cachedContentTokenCount', - ); - const thoughtsTokenCount = coerceUsageCount( - usageMetadata.thoughtsTokenCount, - 'thoughtsTokenCount', - ); - // Stash coerced values so recordAssistantTurn can reuse them - // without re-calling coerceUsageCount inline. - coercedUsage = { - promptTokenCount, - totalTokenCount, - candidatesTokenCount, - cachedContentTokenCount, - thoughtsTokenCount, - }; - const lastPromptTokenCount = hasUsablePromptTokenCount - ? promptTokenCount - : totalTokenCount; - if (lastPromptTokenCount) { - // Always update the per-chat counter so this chat (including - // subagents) can make its own compaction decisions. - // Retain whatever route's counts currently occupy the slots - // before overwriting them: a foreign-keyed slot holds another - // route's state that its next keyed read still needs — mid-send - // compression can leave the slots keyed to the active route - // even though this report comes from the request route (#9506). - if ( - this.tokenCountsRouteKey !== undefined && - this.tokenCountsRouteKey !== routeKey - ) { - this.retainCurrentTokenCounts(); - } - this.lastPromptTokenCount = lastPromptTokenCount; - this.lastPromptTokenCountIsEstimated = false; - this.lastOutputTokenCount = hasUsablePromptTokenCount - ? getUsageOutputTokenCountForPromptEstimate({ - promptTokenCount, - ...(hasUsableTotalTokenCount ? { totalTokenCount } : {}), - candidatesTokenCount, - thoughtsTokenCount, - }) - : 0; - // Attribute these counts to the route that reported them so a - // later model switch invalidates them (#9454). - this.tokenCountsRouteKey = routeKey; - // A fresh API report supersedes anything retained for this - // route while another route owned the slots (#9506). - this.tokenCountsByRouteKey.delete(routeKey); - // Mirror to the global telemetry only when wired — subagents - // pass `telemetryService=undefined` to keep their context usage - // out of the main session's UI counters. - this.telemetryService?.setLastPromptTokenCount( - lastPromptTokenCount, - ); - if (cachedContentTokenCount && this.telemetryService) { - this.telemetryService.setLastCachedContentTokenCount( - cachedContentTokenCount, - ); - } - } - } - - if (isToolResultContinuation) { - // Do not let consumers commit Finished before post-stream validation - // can reject a semantically empty continuation. - for (const candidate of chunk.candidates ?? []) { - if (candidate.finishReason) { - deferredFinishReason ??= candidate.finishReason; - delete candidate.finishReason; - } - } - } - - if ( - !chunk.candidates?.length || - preparations.length > 0 || - !protocolTextWasSuppressed || - !protocolTagDetector.blockingOutput - ) { - yield chunk; - } - } - } catch (e) { - streamError = e; - } - - if ( - streamError === null && - pendingProtocolParts.length > 0 && - (hasToolCall || - pendingProtocolParts.some((part) => part.functionCall !== undefined)) - ) { - protocolTagDetector.finish(); - if (protocolTagDetector.leaked) { - pendingProtocolParts = []; - } else { - const parts = normalizeModelToolCallIds( - takePendingProtocolParts(), - usedToolCallIds, - rawToolCallIdsInCurrentTurn, - reservedToolCallIds, - ); - const chunk = { - candidates: [{ content: { role: 'model', parts } }], - } as GenerateContentResponse; - syncFunctionCallsField(chunk, parts); - hasToolCall ||= parts.some((part) => part.functionCall); - allModelParts.push(...parts); - yield chunk; - } - } - - let thoughtContentPart: Part | undefined; - const thoughtText = allModelParts - .filter((part) => part.thought) - .map((part) => part.text) - .join('') - .trim(); - - if (thoughtText !== '') { - thoughtContentPart = { - text: thoughtText, - thought: true, - }; - - const thoughtSignature = allModelParts.filter( - (part) => part.thoughtSignature && part.thought, - )?.[0]?.thoughtSignature; - if (thoughtContentPart && thoughtSignature) { - thoughtContentPart.thoughtSignature = thoughtSignature; - } - } - - let contentParts = allModelParts.filter((part) => !part.thought); - const consolidatedHistoryParts: Part[] = []; - for (const part of contentParts) { - const lastPart = - consolidatedHistoryParts[consolidatedHistoryParts.length - 1]; - if ( - lastPart?.text && - isValidNonThoughtTextPart(lastPart) && - isValidNonThoughtTextPart(part) - ) { - lastPart.text += part.text; - } else if (isValidContentPart(part)) { - consolidatedHistoryParts.push(part); - } - } - - let contentText = consolidatedHistoryParts - .filter((part) => part.text) - .map((part) => part.text) - .join('') - .trim(); - - // Deferred until after the throw sites below so a protocol-tag leak - // or stream-validation failure cannot dispatch a recovered call that - // the retry path would then execute a second time. - let recoveredChunk: GenerateContentResponse | null = null; - - // XML tool call fallback: some models (e.g. qwen3.8-max-preview in very - // long contexts) occasionally emit tool calls as raw XML in the content - // field instead of using the structured tool_calls array. Detect and - // recover these so the agent loop is not broken. See #8003. - if ( - streamError === null && - !hasToolCall && - hasFinishReason && - contentText && - containsXmlToolCalls(contentText) - ) { - const recovery = tryRecoverXmlToolCalls(contentText); - if (recovery.recovered) { - hasToolCall = true; - // recovery.remainingText is derived from the join of ALL text - // parts, so every text part is consumed. Remove them, reinsert - // remainingText at the first text position so non-text parts - // (inlineData/fileData) keep their original relative order, and - // append functionCallParts at the end. - const textIndices: number[] = []; - for (let i = 0; i < consolidatedHistoryParts.length; i++) { - if (consolidatedHistoryParts[i]!.text !== undefined) - textIndices.push(i); - } - for (let j = textIndices.length - 1; j >= 0; j--) { - consolidatedHistoryParts.splice(textIndices[j]!, 1); - } - const insertAt = Math.min( - textIndices[0] ?? 0, - consolidatedHistoryParts.length, - ); - if (recovery.remainingText) { - consolidatedHistoryParts.splice(insertAt, 0, { - text: recovery.remainingText, - }); - } - consolidatedHistoryParts.push(...recovery.functionCallParts); - // Recompute contentText and contentParts so the JSONL recording - // below stays aligned with in-memory history (--resume fidelity). - contentText = consolidatedHistoryParts - .filter((part) => part.text) - .map((part) => part.text) - .join('') - .trim(); - contentParts = consolidatedHistoryParts; - // Build a synthetic chunk so the agent loop (turn.ts) actually - // executes the recovered tool calls; yielded after the throw sites. - const syntheticChunk = { - candidates: [ - { - content: { role: 'model', parts: recovery.functionCallParts }, - }, - ], - } as GenerateContentResponse; - syncFunctionCallsField(syntheticChunk, recovery.functionCallParts); - recoveredChunk = syntheticChunk; - debugLogger.warn( - `XML tool call fallback: recovered ${recovery.functionCallParts.length} tool call(s) [${recovery.functionCallParts.map((p) => p.functionCall?.name).join(', ')}] from plain text content (contentLength=${contentText.length})`, - ); - } else { - debugLogger.warn( - `XML tool call fallback: detected XML tool calls but recovery was rejected (prose ratio too high or no parameterized blocks), contentLength=${contentText.length}`, - ); - } - } - - if (streamError === null && protocolTagDetector.leaked && !hasToolCall) { - throw new InvalidStreamError( - 'Model response started with leaked protocol tags.', - 'PROTOCOL_TAG_LEAK', - ); - } - - // Stream validation logic: A stream is considered successful if: - // 1. There's a tool call (tool calls can end without explicit finish reasons), OR - // 2. There's a finish reason AND we have non-empty response text or thought text - // - // Thought-only responses remain valid for ordinary user turns. After a - // tool result, they do not advance the agent without text or another - // tool call, so they retry (#7039) — and once that retry budget is - // exhausted the quiet completion is accepted rather than failing the - // run (#9026): some model families legitimately end turns silently - // after a tool result. - const hasAnyContent = contentText || thoughtText; - const lacksVisibleToolResultProgress = - isToolResultContinuation && - (!contentText || contentText === GEMINI_EMPTY_CONTENT_PLACEHOLDER); - let acceptedQuietToolResultCompletion = false; - if ( - streamError === null && - !hasToolCall && - (!hasFinishReason || !hasAnyContent || lacksVisibleToolResultProgress) - ) { - if (!hasFinishReason) { - throw new InvalidStreamError( - 'Model stream ended without a finish reason.', - 'NO_FINISH_REASON', - ); - } - if (lacksVisibleToolResultProgress) { - const truncatedAtMaxTokens = - deferredFinishReason === FinishReason.MAX_TOKENS; - // Only STOP is a complete, non-truncated, non-blocked quiet turn end. - // Unknown converter fall-through values such as - // FINISH_REASON_UNSPECIFIED must fail closed instead of being accepted - // as an empty model turn. - const unsupportedQuietFinishReason = - deferredFinishReason !== FinishReason.STOP; - if ( - truncatedAtMaxTokens || - unsupportedQuietFinishReason || - !acceptQuietToolResultCompletion - ) { - throw new InvalidStreamError( - 'Model stream ended after a tool result without visible progress.', - truncatedAtMaxTokens - ? 'NO_TOOL_RESULT_PROGRESS_MAX_TOKENS' - : 'NO_TOOL_RESULT_PROGRESS', - ); - } - // Retry budget exhausted and the model still ends the turn quietly - // with a valid finish reason (#9026). Accept it as completion. - // When the attempt produced nothing at all, the canonical - // placeholder is appended to `acceptedTurnParts` below — the - // single source for both the JSONL record and the history push, - // keeping user/model alternation well-formed for the next request - // while transcript and history agree. - acceptedQuietToolResultCompletion = true; - debugLogger.warn( - 'Accepting quiet post-tool-result completion after retry budget ' + - 'exhaustion (#9026)', - ); - } else { - throw new InvalidStreamError( - 'Model stream ended with empty response text.', - 'NO_RESPONSE_TEXT', - ); - } - } - - if (recoveredChunk) { - yield recoveredChunk; - } - - // Record assistant turn with raw Content and metadata. Gate matches - // the in-memory `this.history.push` decision below so chat-recording - // JSONL never carries a partial turn we deliberately dropped from - // history: on `--resume` the transcript-load path would otherwise - // re-inject a model turn the in-session run intentionally discarded - // (text-only mid-stream errors, where the Retry re-issues the user - // prompt — a stale partial-text record would bias the resumed - // conversation or surface as duplicate output). - const willPersistToHistory = - streamError === null || - (hasToolCall && - (thoughtContentPart || consolidatedHistoryParts.length > 0)); - // Transport-continuation merge (issue #8094). `allModelParts` is - // per-attempt, so a continuation's parts carry the resumed remainder only. - // Fold the already-delivered prefix back in HERE — into the parts - // themselves, before either durable write — so the JSONL record below and - // the `this.history.push` further down are derived from the same data and - // cannot disagree. Otherwise `--resume` rehydrates a turn that starts - // mid-sentence while the live session shows a coherent answer. - // - // Merging in one place is load-bearing, not tidiness: - // - Computing the record's text and history's text from separate - // expressions lets them drift. They already would: `contentText` is - // trimmed (see its definition above) while the pushed parts are raw, - // so deduping the record against the trimmed text fuses words when the - // remainder opens with whitespace ("The result is" + " 42." → - // "The result is42."). - // - Writing them at different times opens a window. The record is - // appended below, the history push happens after it, and a - // `deferredFinishReason` chunk is yielded after that — a suspension - // point. A consumer abandoning iteration there (an abort inside - // `Turn.run`) would strand a merged record against a remainder-only - // history, and the JSONL is append-only so nothing reconciles it. - // - // Placed after the stream-validation throws above so an empty continuation - // still fails validation on its own merits rather than being masked by the - // prefix. - // - // Success only. On `streamError !== null` the parts must keep matching the - // remainder-only partial that survives in history (the - // `pendingPartialAssistantRecord` path below) — the prefix belongs to an - // attempt that did not survive, and a fresh-restart retry discards it via - // `resetTransportContinuation`. - if (streamError === null && transportContinuationPrefix) { - const textIndex = consolidatedHistoryParts.findIndex(isPlainTextPart); - if (textIndex < 0) { - // Continuation returned no text of its own (e.g. only a functionCall). - // `thoughtContentPart` is prepended separately at the push below, so - // index 0 here is already "after any leading thought part". - consolidatedHistoryParts.unshift({ text: transportContinuationPrefix }); - } else { - const remainderPart = consolidatedHistoryParts[textIndex] as Part & { - text: string; - }; - consolidatedHistoryParts[textIndex] = { - ...remainderPart, - text: mergeDeliveredPrefix( - transportContinuationPrefix, - remainderPart.text, - ), - }; - } - contentText = consolidatedHistoryParts - .filter((part) => part.text) - .map((part) => part.text) - .join('') - .trim(); - } - // The exact parts the accepted turn will carry into `this.history.push` - // below — computed once, before the JSONL record, so an accepted quiet - // completion records exactly what history keeps (including non-text - // parts like inlineData, which have no slot in the text/toolCall - // assembly and would otherwise desync transcript from history on - // `--resume`). - const acceptedTurnParts: Part[] = [ - ...(thoughtContentPart ? [thoughtContentPart] : []), - ...consolidatedHistoryParts, - ]; - if (acceptedQuietToolResultCompletion && acceptedTurnParts.length === 0) { - acceptedTurnParts.push({ text: GEMINI_EMPTY_CONTENT_PLACEHOLDER }); - } - if ( - willPersistToHistory && - (acceptedQuietToolResultCompletion || - thoughtContentPart || - contentText || - hasToolCall || - usageMetadata) - ) { - const contextWindowSize = - this.config.getContentGeneratorConfig()?.contextWindowSize; - const recordArgs = { - model, - message: acceptedQuietToolResultCompletion - ? acceptedTurnParts - : [ - ...(thoughtContentPart ? [thoughtContentPart] : []), - ...(contentText ? [{ text: contentText }] : []), - ...(hasToolCall - ? contentParts - .map(redactStructuredOutputArgsForRecording) - .filter( - ( - p, - ): p is { - functionCall: NonNullable; - } => p !== null, - ) - : []), - ], - tokens: coercedUsage - ? { ...usageMetadata, ...coercedUsage } - : usageMetadata, - contextWindowSize, - ...(goalContext ? { goalContext: { ...goalContext } } : {}), - }; - if (streamError !== null) { - // Stream-error + tool-use partial: defer the JSONL append until - // the outer retry loop decides whether to roll back this attempt. - // If the same send retries successfully, popPartialIfPushed clears - // this stash and the failed attempt never lands on disk; if the - // retry path doesn't apply (unretryable break), the stash is - // flushed at the rethrow site so JSONL stays aligned with the - // partial that survives in-memory. Without this, retry-success - // leaves a failed `model[functionCall]` durable in JSONL and - // `--resume` rehydrates a turn the live session correctly - // discarded. - this.pendingPartialAssistantRecord = recordArgs; - } else { - this.chatRecordingService?.recordAssistantTurn(recordArgs); - } - } - - // Mid-stream failure recovery (Race C in the canonical note above - // `ORPHAN_TOOL_USE_REPAIR_REASON`): if the upstream stream threw - // AFTER a `functionCall` chunk was already yielded — typical on - // weak networks: SSE cut between a tool_use `content_block_stop` - // and the terminal `message_stop` — we persist the partial - // assistant turn so the React scheduler's incoming - // `user[functionResponse]` has a matching `model[tool_use]` to - // pair with. - // - // Plain-text partial turns (no functionCall yielded) are - // deliberately NOT persisted — the Retry path pops the trailing - // user prompt and re-issues it; a stale partial-text model turn - // between them would either bias the retry or surface as a - // duplicate. - if (streamError !== null) { - // Reuse the `willPersistToHistory` gate from the recordAssistantTurn - // block above instead of re-deriving it. When `streamError !== null`, - // `willPersistToHistory` reduces to exactly the original expression - // `hasToolCall && (thoughtContentPart || consolidatedHistoryParts.length > 0)`; - // sharing the single binding eliminates drift risk if one gate is - // tightened without the other and the JSONL recording silently - // desyncs from in-memory history. - if (willPersistToHistory) { - this.history.push({ - role: 'model', - parts: [ - ...(thoughtContentPart ? [thoughtContentPart] : []), - ...consolidatedHistoryParts, - ], - }); - // Track the pushed turn so the outer sendMessageStream retry loop - // can roll it back if it decides to retry the same send. Without - // this, a successful retry would leave the failed attempt's - // partial `model[functionCall]` as a stale leading model turn in - // front of the retry's real response. - this.pendingPartialAssistantTurnIndex = this.history.length - 1; - // Trace the push event so the lifecycle is observable end-to-end: - // dedup in `useGeminiStream.handleCompletedTools` already logs - // `[REPAIR] Dropping ...`, and `repairOrphanedToolUseTurnsInHistory` - // logs `[REPAIR] Synthesized ...`. Without a corresponding - // `[PARTIAL_PUSH]` line here, an investigator looking at a - // stale-partial wedge sees the downstream symptom but has no - // anchor for when/why the partial originated. - debugLogger.warn( - '[PARTIAL_PUSH] Persisting partial assistant turn for ' + - 'mid-stream error recovery (will be rolled back if retry ' + - 'succeeds, kept if break is unretryable). ' + - `pendingIndex=${this.pendingPartialAssistantTurnIndex} ` + - `callIds=${consolidatedHistoryParts - .map((p) => p.functionCall?.id) - .filter((id): id is string => Boolean(id)) - .join(',')} ` + - `error=${ - streamError instanceof Error - ? streamError.message - : String(streamError) - }`, - ); - } - throw streamError; - } - - this.history.push({ - role: 'model', - parts: acceptedTurnParts, - }); - if (deferredFinishReason) { - yield { - candidates: [{ finishReason: deferredFinishReason }], - usageMetadata, - } as GenerateContentResponse; - } - } - - /** - * Merge `pairCount` trailing (user_recovery, model_continuation) pairs back - * into the model turn that precedes them. Used after the output-token - * recovery loop so the internal OUTPUT_RECOVERY_MESSAGE control prompt - * does not persist in durable history as if the user sent it. - * - * Expected tail shape per iteration (walking from the back): - * [..., precedingModel, userRecovery, modelContinuation] - * - * If any pair doesn't match that shape the method bails defensively - * rather than corrupting history. - */ - private coalesceRecoveryPairs(pairCount: number): void { - for (let i = 0; i < pairCount; i++) { - const len = this.history.length; - if (len < 3) return; - - const modelContinuation = this.history[len - 1]!; - const userRecovery = this.history[len - 2]!; - const precedingModel = this.history[len - 3]!; - - if ( - modelContinuation.role !== 'model' || - userRecovery.role !== 'user' || - precedingModel.role !== 'model' - ) { - return; - } - - precedingModel.parts = appendRecoveryContinuationParts( - precedingModel.parts, - modelContinuation.parts, - ); - // Drop the (userRecovery, modelContinuation) pair. - this.history.splice(len - 2, 2); - } - } -} - -/** Visible for Testing */ -export function isSchemaDepthError(errorMessage: string): boolean { - return errorMessage.includes('maximum schema depth exceeded'); -} - -export function isInvalidArgumentError(errorMessage: string): boolean { - return errorMessage.includes('Request contains an invalid argument'); -} +/** @deprecated Import from `llm-chat.js`; retained until a future major release. */ +export * from './llm-chat.js'; diff --git a/packages/core/src/core/genai-compat.ts b/packages/core/src/core/genai-compat.ts index 3860f55f64b..17877fdf9e8 100644 --- a/packages/core/src/core/genai-compat.ts +++ b/packages/core/src/core/genai-compat.ts @@ -17,8 +17,8 @@ export type FinishReason = GenAiFinishReason; export const FinishReason = { STOP: 'STOP' as GenAiFinishReason, MAX_TOKENS: 'MAX_TOKENS' as GenAiFinishReason, - // Content-filter family (mirrors mapGeminiFinishReasonToOpenAI's - // 'content_filter' grouping) — the quiet-completion gate in geminiChat + // Content-filter family (mirrors mapLlmFinishReasonToOpenAI's + // 'content_filter' grouping) — the quiet-completion gate in llmChat // must keep all of these fatal. SAFETY: 'SAFETY' as GenAiFinishReason, RECITATION: 'RECITATION' as GenAiFinishReason, diff --git a/packages/core/src/core/goal-turn-integration.test.ts b/packages/core/src/core/goal-turn-integration.test.ts index 39b325d8a88..7eea0e6e973 100644 --- a/packages/core/src/core/goal-turn-integration.test.ts +++ b/packages/core/src/core/goal-turn-integration.test.ts @@ -12,8 +12,8 @@ import type { ChatRecordingService } from '../services/chatRecordingService.js'; import { ToolNames } from '../tools/tool-names.js'; import type { ErroredToolCall } from './coreToolScheduler.js'; import { CoreToolScheduler } from './coreToolScheduler.js'; -import { GeminiChat, StreamEventType } from './geminiChat.js'; -import { GeminiEventType, Turn } from './turn.js'; +import { LlmChat, StreamEventType } from './llm-chat.js'; +import { LlmEventType, Turn } from './turn.js'; const permit: GoalTurnPermit = { goalId: 'goal-1', @@ -37,7 +37,7 @@ describe('Goal turn evidence propagation', () => { })(), ); const turn = new Turn( - { sendMessageStream } as unknown as GeminiChat, + { sendMessageStream } as unknown as LlmChat, 'goal-prompt', inputPermit, ); @@ -59,7 +59,7 @@ describe('Goal turn evidence propagation', () => { permit, ); const toolRequest = events.find( - (event) => event.type === GeminiEventType.ToolCallRequest, + (event) => event.type === LlmEventType.ToolCallRequest, ); expect(toolRequest?.value).toMatchObject({ callId: 'goal-tool-call', @@ -70,7 +70,7 @@ describe('Goal turn evidence propagation', () => { it('attaches the permit to normal and deferred assistant attempts', async () => { const recordAssistantTurn = vi.fn(); - const chat = new GeminiChat( + const chat = new LlmChat( { getContentGeneratorConfig: () => ({ contextWindowSize: 4096 }), } as unknown as Config, diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/llm-chat.test.ts similarity index 99% rename from packages/core/src/core/geminiChat.test.ts rename to packages/core/src/core/llm-chat.test.ts index 959fc544d4f..5b307189075 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/llm-chat.test.ts @@ -15,14 +15,14 @@ import type { import { ApiError } from '@google/genai'; import { AuthType, type ContentGenerator } from './contentGenerator.js'; import { - GeminiChat, + LlmChat, InvalidStreamError, approvedPlanRedactionText, redactApprovedPlansInHistory, redactStructuredOutputArgsForRecording, StreamEventType, type StreamEvent, -} from './geminiChat.js'; +} from './llm-chat.js'; import { RETRYABLE_STREAM_TRANSPORT_CODES } from './stream-transport-retry.js'; import { getToolCallFingerprint } from './toolCallIdUtils.js'; import { classifyRetryError } from '../utils/retryErrorClassification.js'; @@ -148,9 +148,9 @@ vi.mock('../utils/debugLogger.js', async (importOriginal) => { }; }); -describe('GeminiChat', async () => { +describe('LlmChat', async () => { let mockContentGenerator: ContentGenerator; - let chat: GeminiChat; + let chat: LlmChat; let mockConfig: Config; const config: GenerateContentConfig = {}; @@ -214,13 +214,7 @@ describe('GeminiChat', async () => { // Disable 429 simulation for tests setSimulate429(false); // Reset history for each test by creating a new instance - chat = new GeminiChat( - mockConfig, - config, - [], - undefined, - uiTelemetryService, - ); + chat = new LlmChat(mockConfig, config, [], undefined, uiTelemetryService); }); afterEach(() => { @@ -256,14 +250,14 @@ describe('GeminiChat', async () => { } function chatWithRecorder(recordAssistantTurn: ReturnType) { - return new GeminiChat( + return new LlmChat( mockConfig, config, [], { recordAssistantTurn, recordChatCompression: vi.fn(), - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); } @@ -305,7 +299,7 @@ describe('GeminiChat', async () => { describe('system instruction helpers', () => { it('replaces prior session-start context instead of appending indefinitely', () => { - const isolatedChat = new GeminiChat( + const isolatedChat = new LlmChat( mockConfig, {}, [], @@ -323,7 +317,7 @@ describe('GeminiChat', async () => { }); it('preserves existing system prompt suffixes when replacing session-start context', () => { - const isolatedChat = new GeminiChat( + const isolatedChat = new LlmChat( mockConfig, {}, [], @@ -343,7 +337,7 @@ describe('GeminiChat', async () => { }); it('preserves non-string systemInstruction content when applying session-start context', () => { - const isolatedChat = new GeminiChat( + const isolatedChat = new LlmChat( mockConfig, { systemInstruction: { @@ -365,7 +359,7 @@ describe('GeminiChat', async () => { }); it('applies session-start context synchronously via applySessionStartContext', () => { - const isolatedChat = new GeminiChat( + const isolatedChat = new LlmChat( mockConfig, {}, [], @@ -385,7 +379,7 @@ describe('GeminiChat', async () => { }); it('does not strip legitimate content that only resembles the old plain-text marker', () => { - const isolatedChat = new GeminiChat( + const isolatedChat = new LlmChat( mockConfig, {}, [], @@ -787,7 +781,7 @@ describe('GeminiChat', async () => { /* consume */ } - const replacementChat = new GeminiChat(mockConfig, config); + const replacementChat = new LlmChat(mockConfig, config); replacementChat.enableManualPlanExitNotices(); const replacementStream = await replacementChat.sendMessageStream( 'test-model', @@ -2714,7 +2708,7 @@ describe('GeminiChat', async () => { it('keeps an Anthropic-routed refusal quiet tool result completion fatal (#9026)', async () => { // Anthropic's `refusal` stop_reason is converted to SAFETY by - // mapAnthropicFinishReasonToGemini (anthropicContentGenerator/ + // mapAnthropicFinishReasonToLlm (anthropicContentGenerator/ // converter.ts). Without that mapping it would fall through to // FINISH_REASON_UNSPECIFIED and the armed attempt would accept the // refusal as a quiet "(empty content)" completion, masking the @@ -3803,8 +3797,8 @@ describe('GeminiChat', async () => { }); it('should not update global telemetry when no telemetryService is provided (subagent isolation)', async () => { - // Simulate a subagent GeminiChat: created without a telemetryService - const subagentChat = new GeminiChat(mockConfig, config, []); + // Simulate a subagent LlmChat: created without a telemetryService + const subagentChat = new LlmChat(mockConfig, config, []); const response = (async function* () { yield { @@ -3934,14 +3928,14 @@ describe('GeminiChat', async () => { it('sanitizes a standalone closing thinking tag without retrying valid tool calls', async () => { const recordAssistantTurn = vi.fn(); - const chatWithRecording = new GeminiChat( + const chatWithRecording = new LlmChat( mockConfig, config, [], { recordAssistantTurn, recordChatCompression: vi.fn(), - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); const create = vi.fn().mockImplementation(async () => @@ -4223,7 +4217,7 @@ describe('GeminiChat', async () => { model: 'test-model', contextWindowSize: 264_000, }); - const subagentChat = new GeminiChat(mockConfig, config, [ + const subagentChat = new LlmChat(mockConfig, config, [ { role: 'user', parts: [{ text: 'inherited' }] }, { role: 'model', parts: [{ text: 'inherited reply' }] }, ]); @@ -5066,14 +5060,14 @@ describe('GeminiChat', async () => { { role: 'model', parts: [{ text: 'ack' }] }, ]; const recordChatCompression = vi.fn(); - const chatWithRecording = new GeminiChat( + const chatWithRecording = new LlmChat( mockConfig, config, [], { recordAssistantTurn: vi.fn(), recordChatCompression, - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); const compressSpy = vi @@ -5182,14 +5176,14 @@ describe('GeminiChat', async () => { { role: 'model', parts: [{ text: 'ack' }] }, ]; const recordChatCompression = vi.fn(); - const chatWithRecording = new GeminiChat( + const chatWithRecording = new LlmChat( mockConfig, config, [], { recordAssistantTurn: vi.fn(), recordChatCompression, - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); chatWithRecording.setHistory(originalHistory); @@ -5236,14 +5230,14 @@ describe('GeminiChat', async () => { { role: 'model', parts: [{ text: 'ack' }] }, ]; const recordChatCompression = vi.fn(); - const chatWithRecording = new GeminiChat( + const chatWithRecording = new LlmChat( mockConfig, config, [], { recordAssistantTurn: vi.fn(), recordChatCompression, - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); chatWithRecording.setHistory(originalHistory); @@ -5290,14 +5284,14 @@ describe('GeminiChat', async () => { { role: 'model', parts: [{ text: 'ack' }] }, ]; const recordChatCompression = vi.fn(); - const chatWithRecording = new GeminiChat( + const chatWithRecording = new LlmChat( mockConfig, config, [], { recordAssistantTurn: vi.fn(), recordChatCompression, - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); chatWithRecording.setHistory(originalHistory); @@ -6158,7 +6152,7 @@ describe('GeminiChat', async () => { makeStreamResponse('clamped response'), ); - const chatInstance = new GeminiChat( + const chatInstance = new LlmChat( mockConfig, config, [], @@ -6212,7 +6206,7 @@ describe('GeminiChat', async () => { makeStreamResponse('roomy response'), ); - const chatInstance = new GeminiChat( + const chatInstance = new LlmChat( mockConfig, config, [], @@ -6261,7 +6255,7 @@ describe('GeminiChat', async () => { makeStreamResponse('env ceiling response'), ); - const chatInstance = new GeminiChat( + const chatInstance = new LlmChat( mockConfig, config, [], @@ -6314,7 +6308,7 @@ describe('GeminiChat', async () => { makeStreamResponse('first send'), ); - const chatInstance = new GeminiChat( + const chatInstance = new LlmChat( mockConfig, config, [], @@ -6371,7 +6365,7 @@ describe('GeminiChat', async () => { makeStreamResponse('first send after compression'), ); - const chatInstance = new GeminiChat( + const chatInstance = new LlmChat( mockConfig, config, [ @@ -6421,7 +6415,7 @@ describe('GeminiChat', async () => { }), ); - const chatInstance = new GeminiChat( + const chatInstance = new LlmChat( mockConfig, config, [], @@ -6465,7 +6459,7 @@ describe('GeminiChat', async () => { }), ); - const chatInstance = new GeminiChat( + const chatInstance = new LlmChat( mockConfig, config, [], @@ -6514,7 +6508,7 @@ describe('GeminiChat', async () => { makeStreamResponse('normal response'), ); - const chatInstance = new GeminiChat( + const chatInstance = new LlmChat( mockConfig, config, [], @@ -6592,7 +6586,7 @@ describe('GeminiChat', async () => { }); describe('getHistoryFunctionResponseIds', () => { - // Walk-only accessor used by `useGeminiStream.handleCompletedTools` + // Walk-only accessor used by `useLlmStream.handleCompletedTools` // for the dedup pass. The whole point of this method is to avoid // the multi-millisecond `structuredClone` hit that // `getHistory()` pays on long sessions when only the id Set is @@ -7948,7 +7942,7 @@ describe('GeminiChat', async () => { vi.mocked(mockConfig.getEffectiveInputModalities).mockReturnValue({ pdf: true, }); - chat = new GeminiChat( + chat = new LlmChat( mockConfig, config, [ @@ -12190,14 +12184,14 @@ describe('GeminiChat', async () => { it('discards a completed protocol-tagged response and retries before persistence', async () => { const recordAssistantTurn = vi.fn(); - const chatWithRecording = new GeminiChat( + const chatWithRecording = new LlmChat( mockConfig, config, [], { recordAssistantTurn, recordChatCompression: vi.fn(), - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); vi.mocked(mockContentGenerator.generateContentStream) @@ -12303,14 +12297,14 @@ describe('GeminiChat', async () => { 'retries a JSON tool protocol leak: $name', async ({ leakedJson, trailingText, finishWithContent }) => { const recordAssistantTurn = vi.fn(); - const chatWithRecording = new GeminiChat( + const chatWithRecording = new LlmChat( mockConfig, config, [], { recordAssistantTurn, recordChatCompression: vi.fn(), - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); const leakedText = @@ -12486,14 +12480,14 @@ describe('GeminiChat', async () => { 'retries when a %s interrupts a partial JSON protocol leak', async (middleChunkType) => { const recordAssistantTurn = vi.fn(); - const chatWithRecording = new GeminiChat( + const chatWithRecording = new LlmChat( mockConfig, config, [], { recordAssistantTurn, recordChatCompression: vi.fn(), - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); const leakedText = @@ -12577,14 +12571,14 @@ describe('GeminiChat', async () => { 'preserves leading JSON when a tool call ends without a finish reason (tool call first: %s)', async (toolCallFirst) => { const recordAssistantTurn = vi.fn(); - const chatWithRecording = new GeminiChat( + const chatWithRecording = new LlmChat( mockConfig, config, [], { recordAssistantTurn, recordChatCompression: vi.fn(), - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); const response = JSON.stringify([{ name: 'example', value: 1 }]); @@ -12728,14 +12722,14 @@ describe('GeminiChat', async () => { it('does not retry after a structured tool call has already been emitted', async () => { const recordAssistantTurn = vi.fn(); - const chatWithRecording = new GeminiChat( + const chatWithRecording = new LlmChat( mockConfig, config, [], { recordAssistantTurn, recordChatCompression: vi.fn(), - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); const leakedText = @@ -12976,14 +12970,14 @@ describe('GeminiChat', async () => { vi.useFakeTimers(); try { const recordAssistantTurn = vi.fn(); - const chatWithRecording = new GeminiChat( + const chatWithRecording = new LlmChat( mockConfig, config, [], { recordAssistantTurn, recordChatCompression: vi.fn(), - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); chatWithRecording.setHistory([ @@ -13299,7 +13293,7 @@ describe('GeminiChat', async () => { pendingPartialAssistantTurnIndex: number | null; pendingPartialAssistantRecord: unknown; }; - function plantMarkers(c: GeminiChat): void { + function plantMarkers(c: LlmChat): void { const internal = c as unknown as PrivateFields; internal.pendingPartialAssistantTurnIndex = 0; internal.pendingPartialAssistantRecord = { @@ -13307,7 +13301,7 @@ describe('GeminiChat', async () => { message: [{ functionCall: { id: 'call_test', name: 't', args: {} } }], }; } - function markers(c: GeminiChat): { + function markers(c: LlmChat): { idx: number | null; record: unknown; } { @@ -15878,14 +15872,14 @@ describe('GeminiChat', async () => { // be rolled back so later sends and resumed sessions do not repair an // incomplete call with a synthetic result. const recordAssistantTurn = vi.fn(); - const chatWithRecording = new GeminiChat( + const chatWithRecording = new LlmChat( mockConfig, config, [], { recordAssistantTurn, recordChatCompression: vi.fn(), - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); @@ -15962,8 +15956,8 @@ describe('GeminiChat', async () => { const REPLACEMENT = '[Plan approved and saved to /tmp/p.md]'; - function chatWith(history: Content[]): GeminiChat { - return new GeminiChat({} as unknown as Config, {}, history); + function chatWith(history: Content[]): LlmChat { + return new LlmChat({} as unknown as Config, {}, history); } it('rewrites only the plan arg of the matching exit_plan_mode call', () => { @@ -16229,7 +16223,7 @@ describe('GeminiChat', async () => { const planFile = '/plans/wired-session.md'; mockFileSystem.set(planFile, PLAN); try { - const chat = new GeminiChat( + const chat = new LlmChat( { getPlanFilePath: () => planFile } as unknown as Config, {}, [], @@ -16246,7 +16240,7 @@ describe('GeminiChat', async () => { const planFile = '/plans/ctor-session.md'; mockFileSystem.set(planFile, PLAN); try { - const chat = new GeminiChat( + const chat = new LlmChat( { getPlanFilePath: () => planFile } as unknown as Config, {}, approvedHistory(), @@ -16259,7 +16253,7 @@ describe('GeminiChat', async () => { }); it('setHistory leaves history alone when no plan file exists', () => { - const chat = new GeminiChat( + const chat = new LlmChat( { getPlanFilePath: () => '/plans/never-written.md', } as unknown as Config, @@ -16342,7 +16336,7 @@ describe('GeminiChat', async () => { }); // Compression logic is tested in chatCompressionService.test.ts; this - // suite covers per-chat state on GeminiChat: consecutiveFailures + // suite covers per-chat state on LlmChat: consecutiveFailures // circuit breaker, token-count mutation, history replacement, and // conditional telemetry mirroring. describe('tryCompress (per-chat state)', () => { @@ -16424,7 +16418,7 @@ describe('GeminiChat', async () => { // A subagent-style chat with no telemetryService must NOT touch the // global singleton (per the constructor docstring; per-chat counter // still updates). - const subagentChat = new GeminiChat(mockConfig, config, []); + const subagentChat = new LlmChat(mockConfig, config, []); vi.mocked(uiTelemetryService.setLastPromptTokenCount).mockClear(); mockCompressionService('compressed'); const info = await subagentChat.tryCompress('p3'); @@ -16444,7 +16438,7 @@ describe('GeminiChat', async () => { // The next unforced call should reach the service with // consecutiveFailures=1 (incremented after the first failure). The - // important thing here is that GeminiChat actually forwards the + // important thing here is that LlmChat actually forwards the // updated counter — the service's own threshold logic is tested // separately in chatCompressionService.test.ts. compressSpy.mockClear(); @@ -16494,13 +16488,13 @@ describe('GeminiChat', async () => { it('persists estimated provenance in a compression checkpoint', async () => { const recordChatCompression = vi.fn(); - const recordingChat = new GeminiChat( + const recordingChat = new LlmChat( mockConfig, config, [userMsg('history without usage'), modelMsg('response')], { recordChatCompression, - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); mockCompressionService('compressed'); @@ -16516,13 +16510,13 @@ describe('GeminiChat', async () => { it('preserves an authoritative compression count from the service', async () => { const recordChatCompression = vi.fn(); - const recordingChat = new GeminiChat( + const recordingChat = new LlmChat( mockConfig, config, [userMsg('history'), modelMsg('response')], { recordChatCompression, - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); vi.spyOn(ChatCompressionService.prototype, 'compress').mockResolvedValue({ @@ -16552,7 +16546,7 @@ describe('GeminiChat', async () => { toolResultsThresholdMinutes: 30, toolResultsNumToKeep: 1, }); - const recordingChat = new GeminiChat( + const recordingChat = new LlmChat( mockConfig, config, [ @@ -16567,7 +16561,7 @@ describe('GeminiChat', async () => { ], { recordChatCompression, - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); recordingChat.seedResumeTokenCounts(1000, 0, false); @@ -16589,7 +16583,7 @@ describe('GeminiChat', async () => { // Route-scoped token counts (#9454): API-reported prompt/output token // counts describe the serialization of the route (model + auth type + // endpoint) that produced them. A /model switch rebuilds the content - // generator but keeps this GeminiChat instance, so counts recorded for the + // generator but keeps this LlmChat instance, so counts recorded for the // previous route must be invalidated — otherwise they anchor admission, // clamp, and compression decisions for a different serialization. describe('route-scoped token counts (#9454)', () => { @@ -16753,7 +16747,7 @@ describe('GeminiChat', async () => { toolResultsThresholdMinutes: 30, toolResultsNumToKeep: 1, }); - const fastChat = new GeminiChat( + const fastChat = new LlmChat( mockConfig, config, [ @@ -16768,7 +16762,7 @@ describe('GeminiChat', async () => { ], { recordChatCompression: vi.fn(), - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); fastChat.setLastPromptTokenCount(691_000, false); @@ -16878,7 +16872,7 @@ describe('GeminiChat', async () => { vi.mocked(mockConfig.getModelRouteIdentity).mockImplementation((model) => model ? `${model}@route` : 'active@route', ); - const rescueChat = new GeminiChat( + const rescueChat = new LlmChat( mockConfig, config, [ @@ -16887,7 +16881,7 @@ describe('GeminiChat', async () => { ], { recordAssistantTurn: vi.fn(), - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); // Authoritative count recorded by an earlier override-route turn. @@ -16938,13 +16932,13 @@ describe('GeminiChat', async () => { vi.mocked(mockConfig.getModelRouteIdentity).mockReturnValue( 'active@route', ); - const rescueChat = new GeminiChat( + const rescueChat = new LlmChat( mockConfig, config, [{ role: 'user', parts: [{ text: 'x'.repeat(720_000) }] }], { recordAssistantTurn: vi.fn(), - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); rescueChat.setLastPromptTokenCount(190_000, false); @@ -16999,7 +16993,7 @@ describe('GeminiChat', async () => { vi.mocked(mockConfig.getModelRouteIdentity).mockReturnValue( 'override-model@route', ); - const rescueChat = new GeminiChat( + const rescueChat = new LlmChat( mockConfig, config, [ @@ -17008,7 +17002,7 @@ describe('GeminiChat', async () => { ], { recordAssistantTurn: vi.fn(), - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); // Authoritative count pair recorded by an earlier override-route turn. @@ -17060,13 +17054,13 @@ describe('GeminiChat', async () => { vi.mocked(mockConfig.getModelRouteIdentity).mockReturnValue( 'active@route', ); - const rescueChat = new GeminiChat( + const rescueChat = new LlmChat( mockConfig, config, [{ role: 'user', parts: [{ text: 'x'.repeat(720_000) }] }], { recordAssistantTurn: vi.fn(), - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); rescueChat.setLastPromptTokenCount(150_000, false); @@ -17113,7 +17107,7 @@ describe('GeminiChat', async () => { vi.mocked(mockConfig.getModelRouteIdentity).mockReturnValue( 'active@route', ); - const stampChat = new GeminiChat( + const stampChat = new LlmChat( mockConfig, config, [{ role: 'user', parts: [{ text: 'x'.repeat(720_000) }] }], @@ -17122,7 +17116,7 @@ describe('GeminiChat', async () => { // Successful hard-rescue compression records after the // post-compression guard passes (deferred recording). recordChatCompression: vi.fn(), - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); stampChat.setLastPromptTokenCount(150_000, false); @@ -17194,7 +17188,7 @@ describe('GeminiChat', async () => { vi.mocked(mockConfig.getModelRouteIdentity).mockReturnValue( 'active@route', ); - const stampChat = new GeminiChat( + const stampChat = new LlmChat( mockConfig, config, [{ role: 'user', parts: [{ text: 'x'.repeat(720_000) }] }], @@ -17203,7 +17197,7 @@ describe('GeminiChat', async () => { // Successful hard-rescue compression records after the // post-compression guard passes (deferred recording). recordChatCompression: vi.fn(), - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); stampChat.setLastPromptTokenCount(150_000, false); @@ -17300,7 +17294,7 @@ describe('GeminiChat', async () => { toolResultsThresholdMinutes: 30, toolResultsNumToKeep: 1, }); - const fastChat = new GeminiChat( + const fastChat = new LlmChat( mockConfig, config, [ @@ -17315,7 +17309,7 @@ describe('GeminiChat', async () => { ], { recordChatCompression: vi.fn(), - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); fastChat.setLastPromptTokenCount(691_000, false); @@ -17418,7 +17412,7 @@ describe('GeminiChat', async () => { // each time). After (MAX - 1) failures, the next tryCompress should // still call the service. The actual NOOP-at-threshold gating is the // service's job (and verified separately) — here we just observe that - // GeminiChat keeps forwarding the incremented counter. + // LlmChat keeps forwarding the incremented counter. const compressSpy = vi.spyOn( ChatCompressionService.prototype, 'compress', @@ -17440,7 +17434,7 @@ describe('GeminiChat', async () => { expect(compressSpy.mock.calls[i][1].consecutiveFailures).toBe(i); } // After MAX_CONSECUTIVE_FAILURES failures, the breaker is tripped. - // The next call will still be made by GeminiChat (it does not + // The next call will still be made by LlmChat (it does not // short-circuit on its side), but the service's cheap-gate will NOOP. expect(compressSpy).toHaveBeenCalledTimes(MAX_CONSECUTIVE_FAILURES); await chat.tryCompress('p-last'); @@ -17744,14 +17738,14 @@ describe('GeminiChat', async () => { it('records the recovered functionCall in the JSONL turn (--resume fidelity)', async () => { const recordAssistantTurn = vi.fn(); - const chatWithRecording = new GeminiChat( + const chatWithRecording = new LlmChat( mockConfig, config, [], { recordAssistantTurn, recordChatCompression: vi.fn(), - } as unknown as ConstructorParameters[3], + } as unknown as ConstructorParameters[3], uiTelemetryService, ); const xml = diff --git a/packages/core/src/core/llm-chat.ts b/packages/core/src/core/llm-chat.ts new file mode 100644 index 00000000000..612ba271935 --- /dev/null +++ b/packages/core/src/core/llm-chat.ts @@ -0,0 +1,5722 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// DISCLAIMER: This is a copied version of https://github.com/googleapis/js-genai/blob/main/src/chats.ts with the intention of working around a key bug +// where function responses are not treated as "valid" responses: https://b.corp.google.com/issues/420354090 + +import type { + GenerateContentResponse, + Content, + GenerateContentConfig, + FunctionCall, + SendMessageParameters, + Part, + Tool, + GenerateContentResponseUsageMetadata, +} from '@google/genai'; +import { createUserContent, FinishReason } from './genai-compat.js'; +import { enforceFunctionResponseBudget } from '../tools/tool-response-finalizer.js'; +import { + retryWithBackoff, + isUnattendedMode, + type HeartbeatInfo, +} from '../utils/retry.js'; +import { + isQuotaExhaustedError, + formatQuotaExhaustedMessage, +} from '../utils/quotaErrorDetection.js'; +import { getErrorStatus, isAbortError } from '../utils/errors.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; +import { + containsXmlToolCalls, + tryRecoverXmlToolCalls, +} from './xml-tool-call-fallback.js'; +import { parseAndFormatApiError } from '../utils/errorParsing.js'; +import { + getRateLimitErrorDetails, + getRateLimitRetryDelayMs, + isRateLimitError, + type RetryInfo, +} from '../utils/rateLimit.js'; +import { + classifyRetryError, + isFallbackEligible, +} from '../utils/retryErrorClassification.js'; +import type { Config } from '../config/config.js'; +import type { ContentGenerator, InputModalities } from './contentGenerator.js'; +import { + clampOutputTokensToWindow, + defaultOutputCeiling, + DEFAULT_TOKEN_LIMIT, + OUTPUT_TOKEN_CEILING, + parsePositiveIntegerEnvValue, +} from './tokenLimits.js'; +import { hasCycleInSchema } from '../tools/tools.js'; +import { ToolNames, canonicalToolName } from '../tools/tool-names.js'; +import * as fs from 'node:fs'; +import { PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES } from '../tools/exitPlanMode.js'; +import { isManagedMemoryPath } from '../memory/paths.js'; +import { STRUCTURED_OUTPUT_REDACTED_ARGS } from '../tools/syntheticOutput.js'; +import type { StructuredError } from './turn.js'; +import { + logContentRetry, + logContentRetryFailure, + logApiRetry, + logChatCompression, +} from '../telemetry/loggers.js'; +import { subagentNameContext } from '../utils/subagentNameContext.js'; +import { type ChatRecordingService } from '../services/chatRecordingService.js'; +import { + ChatCompressionService, + computeThresholds, + MAX_CONSECUTIVE_FAILURES, + type CompactTrigger, +} from '../services/chatCompressionService.js'; +import { acquireSleepInhibitor } from '../services/sleepInhibitor.js'; +import { + getFunctionResponseParts, + resolveCompactionTuning, + resolveSlimmingConfig, + slimCompactionInput, +} from '../services/compactionInputSlimming.js'; +import { + InMemoryImagePayloadStore, + buildReattachParts, + countAllInlineImages, + replaceImagePayloadsInPlace, +} from '../services/image-payload-references.js'; +import { + estimateContentTokens, + estimatePromptTokens, + getUsageOutputTokenCountForPromptEstimate, +} from '../services/tokenEstimation.js'; +import { + microcompactHistory, + type MicrocompactMeta, +} from '../services/microcompaction/microcompact.js'; +import { + ContentRetryEvent, + ContentRetryFailureEvent, + ApiRetryEvent, + makeChatCompressionEvent, +} from '../telemetry/types.js'; +import type { UiTelemetryService } from '../telemetry/uiTelemetry.js'; +import { type ChatCompressionInfo, CompressionStatus } from './turn.js'; +import { getContextLengthExceededInfo } from '../utils/contextLengthError.js'; +import { + getStartupContextLength, + isSystemReminderContent, +} from './environmentContext.js'; +import type { SessionStartSource } from '../hooks/types.js'; +import { + getCustomSystemPrompt, + getManualPlanExitSystemReminder, +} from './prompts.js'; +import { isRetryableStreamTransportError } from './stream-transport-retry.js'; +import { + collectToolCallIdsFromHistory, + getFunctionCallFingerprint, + normalizeModelToolCallIds, + reserveModelToolCallId, +} from './toolCallIdUtils.js'; +import { + getToolCallPreparations, + setToolCallPreparations, +} from './tool-call-preparation.js'; +import { InvalidStreamError } from './invalid-stream-error.js'; +import type { GoalTurnPermit } from '../goals/goal-protocol.js'; + +export { InvalidStreamError }; + +const debugLogger = createDebugLogger('QWEN_CODE_CHAT'); +// Gemini can emit this filler after tool results; filtering and validation +// must stay in sync. +const GEMINI_EMPTY_CONTENT_PLACEHOLDER = '(empty content)'; + +function hasCandidateOutput(response: GenerateContentResponse): boolean { + return Boolean( + response.candidates?.some( + (candidate) => + Boolean(candidate.finishReason) || + (candidate.content?.parts?.length ?? 0) > 0, + ), + ); +} + +/** + * True when the chunk carries model output beyond ephemeral reasoning: + * any candidate part without the `thought` flag (text, functionCall, + * inlineData, …). Thought parts stream reasoning that is never recorded + * as the assistant's final response in history, so replaying a request + * that has produced only thought parts cannot duplicate user-visible + * output — the distinction the transport stream retry gate relies on + * (#7832). + */ +function hasNonThoughtCandidateParts( + response: GenerateContentResponse, +): boolean { + return Boolean( + response.candidates?.some((candidate) => + candidate.content?.parts?.some((part) => !part.thought), + ), + ); +} + +function syncFunctionCallsField( + response: GenerateContentResponse, + parts: readonly Part[], +): void { + const functionCalls = parts + .map((part) => part.functionCall) + .filter((call): call is FunctionCall => Boolean(call)); + const value = functionCalls.length > 0 ? functionCalls : undefined; + + let owner: object | null = response; + let descriptor: PropertyDescriptor | undefined; + while (owner && !descriptor) { + descriptor = Object.getOwnPropertyDescriptor(owner, 'functionCalls'); + owner = Object.getPrototypeOf(owner); + } + + if (descriptor?.set) { + ( + response as GenerateContentResponse & { functionCalls?: FunctionCall[] } + ).functionCalls = value; + return; + } + + if (!descriptor || descriptor.writable || descriptor.get) { + Object.defineProperty(response, 'functionCalls', { + value, + writable: true, + configurable: true, + enumerable: true, + }); + } +} + +/** + * Resolves legacy tool-name aliases via the shared `canonicalToolName` so + * the load-side plan redaction keeps matching sessions recorded under a + * pre-migration name, in lockstep with the write-side scheduler. + */ +function canonicalPlanToolName(toolName: string | undefined): string { + if (!toolName) return ''; + return canonicalToolName(toolName); +} + +/** + * Single source of the pointer text that replaces an approved plan's + * `functionCall.args.plan` (#6237). Shared by the tool scheduler's + * post-approval rewrite and the load-side pass below so the two surfaces + * cannot drift. + */ +export function approvedPlanRedactionText(planPath: string): string { + return ( + `[Plan approved and saved to ${planPath}. The plan text was ` + + `removed from the conversation after approval; read that ` + + `file if you need to consult it again.]` + ); +} + +/** + * Pure history-wide variant of the approved-plan redaction: rewrites the + * `plan` argument of every `exit_plan_mode` `functionCall` whose paired + * `functionResponse` carries an approval `llmContent` AND whose plan text + * equals `savedPlanContent` (the current on-disk plan file). Returns a new + * array when anything changed, or null when the history is untouched. + * + * Exported for tests; production callers go through + * `LlmChat.setHistory` / the constructor. + */ +export function redactApprovedPlansInHistory( + history: Content[], + savedPlanContent: string, + planPath: string, +): Content[] | null { + const approved = new Set(); + for (const entry of history) { + if (!entry?.parts) continue; + for (const part of entry.parts) { + const fr = part.functionResponse; + if ( + !fr?.id || + canonicalPlanToolName(fr.name) !== ToolNames.EXIT_PLAN_MODE + ) + continue; + const output = (fr.response as { output?: unknown } | undefined)?.[ + 'output' + ]; + if ( + typeof output === 'string' && + PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES.some((prefix) => + output.startsWith(prefix), + ) + ) { + approved.add(fr.id); + } + } + } + if (approved.size === 0) return null; + + let changed = false; + const out = history.map((entry) => { + if (entry?.role !== 'model' || !entry.parts) return entry; + let entryChanged = false; + const parts = entry.parts.map((part) => { + const fc = part.functionCall; + if ( + !fc?.id || + canonicalPlanToolName(fc.name) !== ToolNames.EXIT_PLAN_MODE + ) + return part; + if (!approved.has(fc.id)) return part; + if ((fc.args ?? {})['plan'] !== savedPlanContent) return part; + entryChanged = true; + return { + ...part, + functionCall: { + ...fc, + args: { ...fc.args, plan: approvedPlanRedactionText(planPath) }, + }, + }; + }); + if (!entryChanged) return entry; + changed = true; + return { ...entry, parts }; + }); + return changed ? out : null; +} + +/** + * Replaces the args on a `structured_output` `functionCall` with the + * same `__redacted` placeholder used by `ToolCallEvent` telemetry + * (`packages/core/src/telemetry/types.ts`). + * + * The chat-recording JSONL (`/chats/.jsonl`) + * persists assistant turns to disk and re-feeds them on + * `--continue` / `--resume`. For `--json-schema` runs the tool args + * ARE the user's structured payload — already emitted on stdout via + * `result` / `structured_result`. Recording them verbatim here would + * mean the same payload (and every validation-failure retry along the + * way) sits on disk indefinitely, contradicting the privacy contract + * documented next to the telemetry redaction. Mirror the placeholder + * here so the chat-recording surface matches. + * + * Non-`structured_output` `functionCall`s pass through untouched. + * + * Exported for tests; callers should prefer the inline use inside + * `recordAssistantTurn` invocation below. + */ +export function redactStructuredOutputArgsForRecording( + part: Part, +): { functionCall: NonNullable } | null { + if (!part.functionCall) return null; + if (part.functionCall.name !== ToolNames.STRUCTURED_OUTPUT) { + return { functionCall: part.functionCall }; + } + return { + functionCall: { + ...part.functionCall, + args: { ...STRUCTURED_OUTPUT_REDACTED_ARGS }, + }, + }; +} + +function isCompressionFailureStatus(status: CompressionStatus): boolean { + return ( + status === CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT || + status === CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY || + status === CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR || + status === CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED + ); +} + +function shouldStopAfterHardRescue( + shouldForceFromHard: boolean, + hardLimit: number, + localPromptTokensAfterCompression: number, +): boolean { + return shouldForceFromHard && localPromptTokensAfterCompression >= hardLimit; +} + +function getHardRescueFailureMessage( + effectiveTokens: number, + hardLimit: number, + compressionInfo: ChatCompressionInfo, + localPromptTokensAfterCompression: number, +): string { + const compressionStatus = + CompressionStatus[compressionInfo.compressionStatus] ?? + String(compressionInfo.compressionStatus); + const tokenCount = + compressionInfo.compressionStatus === CompressionStatus.COMPRESSED + ? Math.max( + compressionInfo.newTokenCount, + localPromptTokensAfterCompression, + ) + : Math.max(effectiveTokens, localPromptTokensAfterCompression); + return ( + `Context is too large to send safely after automatic compression. ` + + `Estimated prompt tokens: ${tokenCount}; hard limit: ${hardLimit}; ` + + `compression status: ${compressionStatus}. ` + + `Start a new session or reduce the resumed history before continuing.` + ); +} + +/** + * Defensive coercion for API-reported token counts. + * + * Hostile providers (broken upstream, OpenAI-compat proxy returning + * `null`/`NaN`, misconfigured override) can yield non-finite or negative + * token counts on `usageMetadata`. This function coerces the four fields that + * feed the compaction gate, its cache-hit telemetry, or OTel spans — + * `promptTokenCount`, `totalTokenCount`, `candidatesTokenCount`, and + * `cachedContentTokenCount`. Letting hostile values + * flow into the compaction gate arithmetic is catastrophic: + * + * - `lastPromptTokenCount + NaN >= hard` is always false → hard-rescue is + * silently disabled, eventually OOMing the V8 heap. + * - `Infinity >= hard` is always true → hard-rescue fires on every send. + * + * Coercing unknown / negative / non-finite to `0` keeps the gate well-defined + * and is a no-op for any provider returning sane values. + * + * `Number.isFinite(-1)` is `true`, so the explicit `>= 0` check is required + * in addition to `isFinite`. + */ +function coerceUsageCount(value: unknown, field?: string): number { + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) { + return value; + } + if (value != null && field) { + debugLogger.warn( + `coerceUsageCount: hostile ${field}=${String(value)}, coercing to 0`, + ); + } + return 0; +} + +export enum StreamEventType { + /** A regular content chunk from the API. */ + CHUNK = 'chunk', + /** A signal that a retry is about to happen. The UI should discard any partial + * content from the attempt that just failed. */ + RETRY = 'retry', + /** Emitted once at the start of the stream when an automatic compression + * pass succeeded. Carries the compression result so callers (the main + * agent UI, subagent loop) can surface it without each call site running + * its own compaction step. */ + COMPRESSED = 'compressed', + /** Emitted when the primary model (or a prior fallback) exhausted its retry + * budget on a capacity/availability error and the system is switching to the + * next fallback model. The UI should discard partial content and display a + * notification about the model switch. */ + MODEL_FALLBACK = 'model_fallback', +} + +/** Information about a model fallback transition. */ +export interface ModelFallbackInfo { + /** The model that exhausted its retry budget. */ + fromModel: string; + /** The model the system is switching to. */ + toModel: string; + /** HTTP status code that triggered the fallback (e.g. 429, 503, 529). */ + statusCode?: number; + /** 1-based index of the fallback in the configured fallback chain. */ + fallbackIndex: number; +} + +export type StreamEvent = + | { type: StreamEventType.CHUNK; value: GenerateContentResponse } + | { + type: StreamEventType.RETRY; + retryInfo?: RetryInfo; + /** When true, the retry is a continuation (recovery) rather than a + * fresh restart (escalation). The UI should keep the accumulated text + * buffer so the continuation appends to it. */ + isContinuation?: boolean; + /** Set when the retry raised the automatic max output token limit. */ + maxOutputTokensEscalated?: number; + } + | { type: StreamEventType.COMPRESSED; info: ChatCompressionInfo } + | { type: StreamEventType.MODEL_FALLBACK; info: ModelFallbackInfo }; + +export interface LlmChatSendOptions { + /** Skip only the configured model fallback chain for this request. */ + disableModelFallbacks?: boolean; +} + +/** @deprecated Use `LlmChatSendOptions`; retained until a future major release. */ +export type GeminiChatSendOptions = LlmChatSendOptions; + +interface TryCompressOptions { + originalTokenCountOverride?: number; + trigger?: CompactTrigger; + /** + * Pending user message about to be sent. Threaded through to the + * compression service's cheap-gate so it can see the real prompt size + * even when `lastPromptTokenCount === 0` (first send after inherited + * history). See `estimatePromptTokens` for the fallback math. + */ + pendingUserMessage?: Content; + /** + * Pre-computed all-inclusive effective prompt count from the caller. When + * set, the cheap-gate uses this instead of recomputing — avoids a second + * `getHistory(true)` clone per send and prevents provider-reported overflow + * counts from double-counting the previous model output. + */ + precomputedEffectiveTokens?: number; + /** Per-request overrides needed to preserve the main request cache prefix. */ + requestGenerationConfig?: GenerateContentConfig; + /** + * Route the enclosing send targets. The entry adoption compares against + * this instead of the active route, so an in-send compression never + * re-adopts counts the active route retained while the request targets + * another one (#9506). Omitted by between-sends callers (manual + * `/compress`), which compress the active route's state. + */ + requestRouteKey?: string; + /** + * Delay writing the compression checkpoint until the caller has run any + * post-compression guards that may roll the in-memory chat state back. + */ + deferChatCompressionRecord?: boolean; + /** + * Forwarded to the compression side-query system prompt. Sourced from + * `/compress ` invocation arg; appended after the base prompt as + * an `Additional Instructions:` block so the summary model can focus + * on the user's stated concern. + */ + customInstructions?: string; +} + +// Model-output validation errors (protocol tag leaks, malformed tool calls) +// and transient stream anomalies (empty streams, no usable text, missing +// finish reason) use an independent retry budget so they do not consume each +// other's or HTTP retries' budgets. +const INVALID_STREAM_RETRY_CONFIG = { + transientMaxRetries: 4, + protocolTagLeakMaxRetries: 2, + initialDelayMs: 2000, +}; + +const TRANSPORT_STREAM_RETRY_CONFIG = { + maxRetries: 2, + initialDelayMs: 1000, + /** + * Budget for *continuation* recovery after a socket-level cut that already + * delivered output (issue #7832). This is a different mechanism from the + * `maxRetries` replay above and therefore has its own budget: a replay + * re-sends the request from scratch and is only legal before any chunk + * reached callers, while a continuation keeps the delivered output and asks + * the model to resume from it. A single long generation can be cut more than + * once by the same gateway idle timeout, so this is sized like + * {@link MAX_OUTPUT_RECOVERY_ATTEMPTS} rather than like the replay budget. + */ + maxContinuationRetries: 3, +}; + +/** + * Pad added when sizing the output clamp from an estimate-derived prompt + * count. This includes a fresh session (`lastPromptTokenCount === 0`) and + * counts propagated through compression or resume before provider usage is + * available. A history-derived count can miss the system prompt, tool + * definitions, and skill content — estimatePromptTokens documents this as + * "typically ~15-20K of under-estimate" — so pad conservatively until + * provider usage arrives. Counts derived from an API baseline may already + * preserve some non-visible overhead; double-counting it is accepted because + * the error direction is safe and provider usage self-corrects it. An + * under-counted prompt is the one way `prompt + max_tokens` can overflow the + * window (issue #5950). Sized to the documented worst case; costs nothing on + * large windows (the output ceiling binds long before the pad matters). + */ +const ESTIMATE_CLAMP_OVERHEAD_PAD = 20_000; + +/** + * Cap on how many routes' token counts are retained while their route is + * not the one owning the chat's count slots (#9506). Route identities are + * bounded by the session's model routes, so this only guards pathological + * selector churn; eviction is FIFO. + */ +const MAX_RETAINED_ROUTE_COUNTS = 8; + +/** + * Max recovery attempts when the escalated response is also truncated. + * Each attempt keeps the partial response in history and injects a recovery + * message so the model can continue from where it left off. + */ +const MAX_OUTPUT_RECOVERY_ATTEMPTS = 3; + +/** + * The resume instruction shared by every recovery user-turn, whatever cut the + * response short. Only the lead-in sentence naming the cause differs between + * the paths below, so the instruction itself lives here: tuning it (say, to + * curb recap behaviour) has to apply to both, and duplicating it invites one + * path to be updated while the other silently keeps the old wording. + */ +const RECOVERY_RESUME_INSTRUCTION = + 'Resume directly — no apology, no recap of what you were doing. Pick up ' + + 'mid-thought if that is where the cut happened. Break remaining work into ' + + 'smaller pieces.'; + +/** + * Recovery message injected as a user turn when the model's output is + * truncated even after token escalation. Instructs the model to resume + * without repeating itself and to break remaining work into smaller steps. + */ +const OUTPUT_RECOVERY_MESSAGE = `Output token limit hit. ${RECOVERY_RESUME_INSTRUCTION}`; + +/** + * Lead-in for the same recovery user-turn when the cause was a socket-level + * cut mid-stream rather than the output token limit (issue #7832). Gateways + * that cap SSE connection lifetime close long generations after a few + * minutes; the response so far is already on the caller's screen, so the only + * safe recovery is to resume from it. Deliberately shares + * {@link RECOVERY_RESUME_INSTRUCTION} with {@link OUTPUT_RECOVERY_MESSAGE} — + * the model does not need to know which limit it hit, only that it was cut + * off and must not restart. + */ +const TRANSPORT_CONTINUATION_MESSAGE = `The connection dropped mid-response. ${RECOVERY_RESUME_INSTRUCTION}`; + +/** + * Maximum length of the previous-response tail embedded inside the + * `` block of the recovery user-turn. Chosen as a + * pragmatic balance: large enough to give the model enough trailing context to + * resume coherently (covers ~200–400 tokens of prose, or a multi-row Markdown + * table), and small enough to keep the recovery prompt well under any + * provider's input budget even when combined with the rest of history. + */ +const OUTPUT_RECOVERY_TAIL_CHARS = 1200; + +/** + * Hard cap on the inner overlap/contained-prefix scan loops. Bounds both the + * suffix-anchored overlap search in {@link getRecoveryContinuationSuffix} and + * the contained-prefix scan in {@link findContainedRecoveryPrefixReplayLength} + * so recovery dedup stays O(min(previous, continuation, 4000)) in iteration + * count instead of unbounded against pathologically large continuations. + */ +const RECOVERY_OVERLAP_MAX_SCAN_CHARS = 4000; + +/** + * Minimum byte-length before a plain-text overlap (between previous tail and + * continuation prefix) is considered "significant" enough to dedup. Short + * coincidental matches like `". "`, `"the "`, or `", and "` happen routinely + * across unrelated turns; requiring ≥6 bytes makes accidental matches on + * common short suffixes vanishingly unlikely while still catching meaningful + * replayed phrases. + */ +const RECOVERY_OVERLAP_MIN_BYTES = 6; + +/** + * Companion floor in *code points* for prose overlaps. The byte floor alone is + * too permissive for CJK: a single Chinese character is 3 UTF-8 bytes, so + * `RECOVERY_OVERLAP_MIN_BYTES = 6` would accept a coincidental 2-character + * overlap like `"我们"` / `"但是"` that is extremely common across unrelated + * Chinese turns. Requiring at least 4 code points in addition to the byte + * floor makes CJK collisions need a 4-character coincidence (~10⁻⁵ when + * each character is independent), without raising the bar for ASCII (4 ASCII + * chars is only 4 bytes — still gated by the 6-byte floor, so ASCII effectively + * needs ≥6 chars). Structural anchors (`#|`\n) are exempted because the + * structural floor already governs them and structural collisions are far + * rarer than prose. + */ +const RECOVERY_OVERLAP_MIN_CHARS = 4; + +/** + * Lower floor for overlaps that contain Markdown structural characters + * (`#`, `|`, backtick, newline). Structural anchors are far less likely to + * collide coincidentally than prose — a 4-byte overlap like `"| a "` or + * `"## "` is almost certainly a replayed block-level marker, so we accept a + * smaller match to catch table/heading replays that the 6-byte prose floor + * would otherwise miss. + */ +const RECOVERY_STRUCTURAL_OVERLAP_MIN_BYTES = 4; +// Plain-prose substring matches outside the suffix-anchored path are very +// prone to false positives on common opener phrases ("In summary, …", "Here is +// the …"). The contained-prefix replay path is reserved for replayed Markdown +// blocks (tables, headings, fenced code), so we require both a structural +// anchor at the start of the prefix and a substantially larger byte floor than +// the suffix path uses. This intentionally errs on the side of leaving rare +// duplicates in history rather than silently dropping legitimate continuation. +const RECOVERY_CONTAINED_PREFIX_MIN_BYTES = 12; +// Limit the substring search to the immediate truncation tail so a coincidental +// match thousands of characters earlier in the previous turn cannot win. +const RECOVERY_CONTAINED_TAIL_LOOKBACK_CHARS = 400; + +function byteLength(text: string): number { + return Buffer.byteLength(text, 'utf8'); +} + +function isSignificantRecoveryOverlap(overlap: string): boolean { + const overlapBytes = byteLength(overlap); + // This is intentionally a loose "contains any of these chars" check rather + // than a strict Markdown-block-anchor parse: an overlap that picks up `#`, + // `` ` ``, `|`, or `\n` is *probably* a replayed structural marker, and + // the 4-byte structural floor only differs from the 6-byte prose floor by + // a 2-byte window. The worst realistic over-classification (4–5 byte prose + // fragments like `"C#dev"` or `"a|b|c"` slipping through the structural + // path instead of the prose path) still requires that fragment to be + // identical at the truncation boundary on both sides, which is far rarer + // than the structural-replay scenarios this lower floor exists to catch. + const hasMarkdownStructure = /[#|`\n]/.test(overlap); + if ( + hasMarkdownStructure && + overlapBytes >= RECOVERY_STRUCTURAL_OVERLAP_MIN_BYTES + ) { + return true; + } + // Prose overlaps must clear *both* the byte floor (covers ASCII) and the + // code-point floor (covers CJK). Counting code points via the spread + // iterator handles surrogate pairs correctly so emoji do not double-count. + const overlapChars = [...overlap].length; + return ( + overlapBytes >= RECOVERY_OVERLAP_MIN_BYTES && + overlapChars >= RECOVERY_OVERLAP_MIN_CHARS + ); +} + +/** + * Returns true if `text` opens with a Markdown block-level structural marker + * (table row, fenced code, ATX heading, blockquote, list item). Leading + * whitespace/newline chars are skipped because providers often prepend them + * when restarting a block — some completion APIs re-emit the suffix with + * leading spaces or tabs, not just newlines. The marker must appear at the + * start of a line and be followed by the syntactic gap the spec requires + * (e.g. `# ` not `#abc`), so incidental `#` or `|` characters in prose do + * not count. + * + * The table-row alternation requires either ≥3 pipes (GFM tables need at + * least 2 cells, i.e. 3 separator pipes) *or* a separator row (`|---|`, + * `|:---:|`, etc.). A bare `|expression|` in technical prose has only 2 + * pipes and no separator syntax, so it is intentionally rejected — that + * pattern is not a valid GFM table row anyway. + */ +function startsWithMarkdownStructuralAnchor(text: string): boolean { + const trimmed = text.replace(/^\s+/, ''); + return /^(\|[^\n]*\|[^\n]*\||\|[\s\-:]+\||#{1,6} |```|>\s|[-*+] |\d+\. )/.test( + trimmed, + ); +} + +function findContainedRecoveryPrefixReplayLength( + previousText: string, + continuationText: string, +): number { + // Only consider replaying the *immediate* tail of the previous response. + // Earlier matches would let a coincidental substring far above the + // truncation point silently delete legitimate continuation text. + const previousTail = + previousText.length > RECOVERY_CONTAINED_TAIL_LOOKBACK_CHARS + ? previousText.slice(-RECOVERY_CONTAINED_TAIL_LOOKBACK_CHARS) + : previousText; + + // The contained-prefix path is intended *only* for replayed Markdown blocks + // (tables, headings, fenced code) that providers re-emit when resuming after + // MAX_TOKENS. Prose replays — even ones that briefly coincide with the + // previous tail — are out of scope: dropping them would silently lose user- + // visible content. Require a structural anchor at the very start of the + // continuation before considering any contained-prefix match at all. + if (!startsWithMarkdownStructuralAnchor(continuationText)) { + return 0; + } + + // The anchor check above tolerates leading whitespace because some providers + // re-emit the replayed block with extra leading spaces/tabs. The actual + // substring match must use the *trimmed* continuation, otherwise a + // continuation like `" ### Heading"` would never match a previous tail + // containing `"### Heading"` (no leading whitespace). Track the offset so + // the returned length consumes the leading whitespace too — keeping the + // caller's `continuationText.slice(replayedLength)` invariant intact. + const leadingMatch = continuationText.match(/^\s+/); + const leadingWhitespaceLength = leadingMatch?.[0].length ?? 0; + const trimmedContinuation = continuationText.slice(leadingWhitespaceLength); + + const maxPrefix = Math.min( + previousTail.length, + trimmedContinuation.length, + RECOVERY_OVERLAP_MAX_SCAN_CHARS, + ); + + for (let length = maxPrefix; length > 0; length -= 1) { + const prefix = trimmedContinuation.slice(0, length); + if ( + byteLength(prefix) >= RECOVERY_CONTAINED_PREFIX_MIN_BYTES && + previousTailContainsAtLineBoundary(previousTail, prefix) + ) { + return leadingWhitespaceLength + length; + } + } + + return 0; +} + +/** + * Symmetric line-boundary check for the contained-prefix scan: returns true + * iff `prefix` occurs in `previousTail` starting at index 0 or immediately + * after a newline. The structural-anchor check on the continuation side only + * enforces that the *continuation* starts at a Markdown block boundary; + * without this guard, a plain substring match could land mid-paragraph in + * `previousTail` (e.g. inside a code block that contains the literal string + * `"### Heading\nfoo"`) and silently strip legitimate continuation text. All + * occurrences are checked so a benign mid-paragraph hit doesn't shadow a real + * line-anchored replay later in the tail. + */ +function previousTailContainsAtLineBoundary( + previousTail: string, + prefix: string, +): boolean { + let searchFrom = 0; + while (searchFrom <= previousTail.length) { + const matchIndex = previousTail.indexOf(prefix, searchFrom); + if (matchIndex === -1) { + return false; + } + if (matchIndex === 0 || previousTail.charAt(matchIndex - 1) === '\n') { + return true; + } + searchFrom = matchIndex + 1; + } + return false; +} + +/** + * Compute the portion of `continuationText` that should be appended to + * `previousText` after a MAX_TOKENS recovery, stripping any overlap that the + * provider replayed at the boundary. + * + * The empty-input guard (`previousText.length === 0 || + * continuationText.length === 0`) is *defensive only*. The sole production + * caller is {@link appendRecoveryContinuationParts}, which already short- + * circuits when either side has no plain-text part — neither branch of the + * guard can fire from production code. It exists so that anyone reusing this + * helper directly (e.g. a future unit test, a refactor that bypasses the + * caller's filter) cannot crash or read out of bounds. We deliberately leave + * the guard in place rather than rely on the caller's invariant alone. + */ +function getRecoveryContinuationSuffix( + previousText: string, + continuationText: string, +): string { + if (previousText.length === 0 || continuationText.length === 0) { + return continuationText; + } + + if ( + previousText.endsWith(continuationText) && + isSignificantRecoveryOverlap(continuationText) + ) { + return ''; + } + + const maxOverlap = Math.min( + previousText.length, + continuationText.length, + RECOVERY_OVERLAP_MAX_SCAN_CHARS, + ); + + // Worst-case complexity here is O(n²): up to RECOVERY_OVERLAP_MAX_SCAN_CHARS + // iterations, each calling `previousText.endsWith(overlap)` plus + // `byteLength(overlap)` (both O(m)). At the current 4000-char scan cap that + // is ~16M char-ops per recovery event, which is fine because recovery is + // rare and the cap is small. If the cap ever grows materially, this can be + // rewritten with a precomputed Z-array / failure function on + // `continuationText` to scan once instead of repeatedly slicing/comparing. + for (let length = maxOverlap; length > 0; length -= 1) { + const overlap = continuationText.slice(0, length); + if ( + isSignificantRecoveryOverlap(overlap) && + previousText.endsWith(overlap) + ) { + return continuationText.slice(length); + } + } + + // Providers/models frequently resume a MAX_TOKENS recovery from an anchor + // that appears near the tail of the previous response, rather than from the + // exact last byte. Drop that replayed leading prefix before coalescing the + // recovery model turn into durable history; otherwise later turns inherit + // duplicated Markdown tables/prose even if the live UI suppresses them. + const containedPrefixLength = findContainedRecoveryPrefixReplayLength( + previousText, + continuationText, + ); + if (containedPrefixLength > 0) { + const replayedPrefix = continuationText.slice(0, containedPrefixLength); + let suffix = continuationText.slice(containedPrefixLength); + if ( + suffix.length > 0 && + replayedPrefix.endsWith('\n') && + !previousText.endsWith('\n') && + !suffix.startsWith('\n') + ) { + suffix = `\n${suffix}`; + } + return suffix; + } + + return continuationText; +} + +/** + * Join already-delivered text to the continuation that resumes it, dropping + * any tail the model replayed. + * + * The single definition of "merged turn text" for the transport-continuation + * path. Both the durable JSONL record and in-memory history are built from one + * call to this (see `processStreamResponse`), so the two storage layers cannot + * drift apart if the dedup rule ever changes — the same reason the + * `willPersistToHistory` gate is a shared binding rather than two copies of + * one expression. + */ +function mergeDeliveredPrefix( + deliveredText: string, + continuationText: string, +): string { + return ( + deliveredText + + getRecoveryContinuationSuffix(deliveredText, continuationText) + ); +} + +function isPlainTextPart(part: Part | undefined): part is Part & { + text: string; +} { + // Delegate to the shared predicate used by normal history consolidation + // (see `isValidNonThoughtTextPart` below) so the recovery-merge path and + // the consolidated-history path agree on what counts as "plain text". + // Keeping the type predicate here gives callers `part.text: string` + // narrowing; the underlying checks (thought, thoughtSignature, function*, + // inlineData, fileData) live in one place. + return part !== undefined && isValidNonThoughtTextPart(part); +} + +function getPlainTextFromParts(parts: Part[] | undefined): string { + return (parts ?? []) + .filter(isPlainTextPart) + .map((part) => part.text) + .join(''); +} + +/** + * Sanitize the previous-response tail before embedding it inside the + * `...` block. + * + * If the model's own truncated output happened to contain the literal + * closing delimiter (e.g. while generating XML/HTML examples), the + * recovery prompt's structure would break — the model would see a + * prematurely closed tag and misinterpret the suffix boundary. We + * neutralize any literal opening/closing delimiter occurrences by + * inserting a zero-width space between the angle bracket and the rest + * of the tag. The text remains visually identical to the model and + * preserves the recovery instruction's intent, but no longer collides + * with our delimiter scan. + */ +function sanitizeRecoverySuffixTail(tail: string): string { + if ( + !tail.includes('') && + !tail.includes('') + ) { + return tail; + } + return tail + .replace(/<\/previous_response_suffix>/g, '<​/previous_response_suffix>') + .replace(//g, '<​previous_response_suffix>'); +} + +/** + * Build a recovery user-turn from the text the model already produced. + * + * Shared by both continuation paths: output-token truncation (which reads the + * partial turn back out of history) and mid-stream transport cuts (which + * cannot, because a text-only partial is deliberately never persisted — see + * `processStreamResponse`). `lead` states the cause; everything after it is + * identical so the two paths cannot drift in how they fence the suffix. + */ +function buildRecoveryMessageFromText(lead: string, previousText: string) { + if (previousText.trim().length === 0) { + return lead; + } + + const rawTail = + previousText.length > OUTPUT_RECOVERY_TAIL_CHARS + ? previousText.slice(-OUTPUT_RECOVERY_TAIL_CHARS) + : previousText; + const tail = sanitizeRecoverySuffixTail(rawTail); + + return ( + `${lead}\n\n` + + 'The previous assistant response ended with this exact suffix. ' + + 'Do not repeat any line, table row, code line, or prose that already ' + + 'appears in it; output only text that comes after this suffix:\n\n' + + '\n' + + tail + + '\n' + ); +} + +function buildOutputRecoveryMessage(previousModelTurn: Content | undefined) { + return buildRecoveryMessageFromText( + OUTPUT_RECOVERY_MESSAGE, + previousModelTurn?.role === 'model' + ? getPlainTextFromParts(previousModelTurn.parts) + : '', + ); +} + +/** + * Coalesce a recovery continuation turn into the preceding (truncated) model + * turn, dropping any replayed overlap. + * + * Coupling with `processStreamResponse`. This function assumes the parts + * arrays it receives were produced by {@link LlmChat.processStreamResponse} + * — i.e. all plain-text streaming chunks from a given turn have been + * consolidated in place into a single text part via `lastPart.text += + * part.text`. The dedup logic only inspects the *last* plain-text part of + * `previousParts` and the *first* plain-text part of `continuationParts`, so + * if a future refactor of `processStreamResponse` ever emits multiple adjacent + * unconsolidated text parts per turn, this function would compare the + * continuation against only the trailing fragment and miss real overlaps with + * earlier fragments. Both functions live in this file precisely so the + * coupling is reviewable in a single window. + * + * Return-value shape. The returned array preserves the *shape convention* of + * `processStreamResponse` output: `[thoughtPart?, ...consolidatedTextParts, + * ...nonTextParts]`. {@link LlmChat.coalesceRecoveryPairs} relies on this + * by feeding the merged result back as `previousParts` on the next recovery + * iteration; if the shape ever diverges, multi-iteration recovery dedup would + * fail silently against the wrong part. + */ +function appendRecoveryContinuationParts( + previousParts: Part[] | undefined, + continuationParts: Part[] | undefined, +): Part[] { + const mergedParts = [...(previousParts ?? [])]; + const nextParts = [...(continuationParts ?? [])]; + + // `processStreamResponse` orders parts as + // `[thoughtPart?, ...consolidatedHistoryParts]`, so for thinking models the + // first element of `nextParts` is the recovery turn's thought, not its + // plain-text continuation. Similarly the previous truncated turn may end + // with a non-text part. Scan both sides for the dedup-relevant plain-text + // anchor instead of locking onto the boundary indices, otherwise thinking + // models leak duplicated text into durable history because the dedup block + // gets skipped wholesale. + const previousTextIndex = findLastPlainTextPartIndex(mergedParts); + const continuationTextIndex = nextParts.findIndex(isPlainTextPart); + + if (previousTextIndex >= 0 && continuationTextIndex >= 0) { + const previousTextPart = mergedParts[previousTextIndex] as Part & { + text: string; + }; + const continuationTextPart = nextParts[continuationTextIndex] as Part & { + text: string; + }; + const suffix = getRecoveryContinuationSuffix( + previousTextPart.text, + continuationTextPart.text, + ); + if (suffix.length > 0) { + // Allocate a fresh part rather than mutating in place: `mergedParts` + // shares element references with the caller's history slot, and any + // downstream caller that cached a `part` reference would observe the + // mutation. Cheap allocation; eliminates a fragile invariant. + mergedParts[previousTextIndex] = { + ...previousTextPart, + text: previousTextPart.text + suffix, + }; + } + // Drop the matched continuation text part: a non-empty suffix has already + // been appended above, and an empty suffix means the part was a pure + // replay of the previous tail and should be discarded so it does not + // duplicate into history. Hoist any non-text parts that preceded the + // matched text on the continuation side (typically the recovery turn's + // thought) so they land *before* the merged text part — thinking-model + // providers (Gemini 2.5+, Anthropic, OpenAI o-series) validate + // thought-signature provenance and expect a thought to precede the + // content it generated. Trailing non-text parts (tool calls etc.) keep + // their position via the final `[...mergedParts, ...nextParts]` concat. + const leadingNonTextParts = nextParts.splice(0, continuationTextIndex); + nextParts.shift(); + if (leadingNonTextParts.length > 0) { + mergedParts.splice(previousTextIndex, 0, ...leadingNonTextParts); + } + } + + return [...mergedParts, ...nextParts]; +} + +function findLastPlainTextPartIndex(parts: Part[]): number { + for (let i = parts.length - 1; i >= 0; i -= 1) { + if (isPlainTextPart(parts[i])) { + return i; + } + } + return -1; +} + +/** + * Options for retrying on rate-limit throttling errors returned as stream content. + * Starts at 60s to match DashScope's per-minute quota window, then backs off + * across repeated stream-side throttling errors. + * 10 retries aligns with Claude Code's retry behavior. + */ +const RATE_LIMIT_RETRY_OPTIONS = { + maxRetries: 10, + initialDelayMs: 60000, + maxDelayMs: 5 * 60 * 1000, +}; + +/** + * Creates a promise that resolves after the specified delay, but can be + * resolved early by calling the returned `skip` function. + * + * If an `AbortSignal` is provided and it fires before the delay completes, + * the promise rejects so the caller's `await` throws and normal error + * propagation takes over (e.g. the retry loop breaks and the generator exits). + */ +function delay( + delayMs: number, + signal?: AbortSignal, +): { + promise: Promise; + skip: () => void; +} { + let resolveRef: () => void; + let timeoutId: ReturnType; + + const promise = new Promise((resolve, reject) => { + resolveRef = resolve; + + if (signal?.aborted) { + reject(signal.reason); + return; + } + + timeoutId = setTimeout(resolve, delayMs); + + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timeoutId); + reject(signal.reason); + }, + { once: true }, + ); + }); + + return { + promise, + skip: () => { + clearTimeout(timeoutId); + resolveRef(); + }, + }; +} + +/** + * Returns true if the response is valid, false otherwise. + * + * The DashScope provider may return the last 2 chunks as: + * 1. A choice(candidate) with finishReason and empty content + * 2. Empty choices with usage metadata + * We'll check separately for both of these cases. + */ +function isValidResponse(response: GenerateContentResponse): boolean { + if (response.usageMetadata) { + return true; + } + + if (response.candidates === undefined || response.candidates.length === 0) { + return false; + } + + if (response.candidates.some((candidate) => candidate.finishReason)) { + return true; + } + + const content = response.candidates[0]?.content; + return content !== undefined && isValidContent(content); +} + +export function isValidNonThoughtTextPart(part: Part): boolean { + return ( + typeof part.text === 'string' && + !part.thought && + !part.thoughtSignature && + // Technically, the model should never generate parts that have text and + // any of these but we don't trust them so check anyways. + !part.functionCall && + !part.functionResponse && + !part.inlineData && + !part.fileData + ); +} + +function isValidContent(content: Content): boolean { + if (content.parts === undefined || content.parts.length === 0) { + return false; + } + for (const part of content.parts) { + if (part === undefined || Object.keys(part).length === 0) { + return false; + } + if (!isValidContentPart(part)) { + return false; + } + } + return true; +} + +function isValidContentPart(part: Part): boolean { + const isInvalid = + !part.thought && + !part.thoughtSignature && + part.text !== undefined && + part.text === '' && + part.functionCall === undefined; + + return !isInvalid; +} + +const UPSTREAM_DEGRADED_PLACEHOLDER = '(request timeout)'; + +function degradedPlaceholderError(): InvalidStreamError { + return new InvalidStreamError( + 'Model response is an upstream fail-fast placeholder.', + 'UPSTREAM_DEGRADED_RESPONSE', + ); +} + +function isDegradedPlaceholderTurn(content: Content): boolean { + const parts = content.parts ?? []; + return ( + parts.length > 0 && + parts.every( + (part) => + part.functionCall === undefined && + (part.thought || part.text !== undefined), + ) && + parts + .filter((part) => !part.thought) + .map((part) => part.text ?? '') + .join('') + .trim() === UPSTREAM_DEGRADED_PLACEHOLDER + ); +} + +async function* rejectDegradedPlaceholderResponse( + stream: AsyncGenerator, +): AsyncGenerator { + const pending: GenerateContentResponse[] = []; + let text = ''; + let passthrough = false; + + for await (const chunk of stream) { + if (passthrough) { + yield chunk; + continue; + } + + const parts = chunk.candidates?.[0]?.content?.parts ?? []; + if ( + parts.some( + (part) => + part.functionCall !== undefined || + (!part.thought && part.text === undefined), + ) + ) { + yield* pending; + pending.length = 0; + yield chunk; + passthrough = true; + continue; + } + + const chunkText = parts + .filter((part) => !part.thought) + .map((part) => part.text ?? '') + .join(''); + if (pending.length === 0 && chunkText === '') { + yield chunk; + continue; + } + + pending.push(chunk); + text += chunkText; + const trimmed = text.trim(); + if (trimmed && !UPSTREAM_DEGRADED_PLACEHOLDER.startsWith(trimmed)) { + yield* pending; + pending.length = 0; + passthrough = true; + } + } + + if (passthrough) return; + if (text.trim() === UPSTREAM_DEGRADED_PLACEHOLDER) { + throw degradedPlaceholderError(); + } + yield* pending; +} + +/** + * Validates the history contains the correct roles. + * + * @throws Error if the history does not start with a user turn. + * @throws Error if the history contains an invalid role. + */ +function validateHistory(history: Content[]) { + for (const content of history) { + if (content.role !== 'user' && content.role !== 'model') { + throw new Error(`Role must be user or model, but got ${content.role}.`); + } + } +} + +/** + * Extracts the curated (valid) history from a comprehensive history. + * + * @remarks + * The model may sometimes generate invalid or empty contents(e.g., due to safety + * filters or recitation). Extracting valid turns from the history + * ensures that subsequent requests could be accepted by the model. + */ +function extractCuratedHistory(comprehensiveHistory: Content[]): Content[] { + if (comprehensiveHistory === undefined || comprehensiveHistory.length === 0) { + return []; + } + const curatedHistory: Content[] = []; + const length = comprehensiveHistory.length; + let i = 0; + while (i < length) { + if (comprehensiveHistory[i].role === 'user') { + appendCuratedContent(curatedHistory, comprehensiveHistory[i]); + i++; + } else { + const modelOutput: Content[] = []; + let isValid = true; + while (i < length && comprehensiveHistory[i].role === 'model') { + modelOutput.push(comprehensiveHistory[i]); + if (isValid && !isValidContent(comprehensiveHistory[i])) { + isValid = false; + } + i++; + } + if (isValid) { + curatedHistory.push( + ...modelOutput.filter((turn) => !isDegradedPlaceholderTurn(turn)), + ); + } + } + } + return curatedHistory; +} + +function appendCuratedContent( + curatedHistory: Content[], + content: Content, +): void { + const lastIndex = curatedHistory.length - 1; + const lastContent = lastIndex >= 0 ? curatedHistory[lastIndex] : undefined; + + if (content.role === 'user' && lastContent?.role === 'user') { + curatedHistory[lastIndex] = { + ...lastContent, + parts: [...(lastContent.parts ?? []), ...(content.parts ?? [])], + }; + return; + } + + curatedHistory.push(content); +} + +function copyContentContainer(content: Content): Content { + return { + ...content, + ...(content.parts ? { parts: content.parts.map(copyPartContainer) } : {}), + }; +} + +function copyPartContainer(part: Part): Part { + const nested = getFunctionResponseParts(part); + if (!nested) return { ...part }; + return { + ...part, + functionResponse: { + ...part.functionResponse, + parts: nested.map((inner) => ({ ...inner })), + }, + }; +} + +function stripThoughtPartsFromContent(content: Content): Content | null { + if (!content.parts) { + return content; + } + + const parts = content.parts.filter((part) => !(part as Part).thought); + if (parts.length === 0) { + return null; + } + + return { + ...content, + parts, + }; +} + +const PROTOCOL_TAG_PREFIXES = [ + '\s*<\/function>/iy; + +function hasLeakedToolCallTags(text: string): boolean { + let inString = false; + let escaped = false; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + if (inString) { + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '"') inString = false; + } else if (char === '"') { + inString = true; + } else if (char === '}' || char === ']') { + LEAKED_TOOL_CALL_TAGS.lastIndex = i; + if (LEAKED_TOOL_CALL_TAGS.test(text)) return true; + } + } + return false; +} + +class LeadingProtocolTagLeakDetector { + private state: 'detecting' | 'json' | 'clean' | 'leaked' = 'detecting'; + private buffer = ''; + + accept(text: string): string { + if (this.state === 'clean') return text; + if (this.state === 'leaked') return ''; + + this.buffer += text; + if (this.state === 'json') return ''; + const candidate = this.buffer.trimStart().toLowerCase(); + if (!candidate) return ''; + if (PROTOCOL_TAG_PREFIXES.some((prefix) => prefix.startsWith(candidate))) { + return ''; + } + + for (const prefix of PROTOCOL_TAG_PREFIXES) { + if ( + candidate.startsWith(prefix) && + /[\s/>]/.test(candidate[prefix.length] ?? '') + ) { + this.state = 'leaked'; + this.buffer = ''; + return ''; + } + } + if (candidate.startsWith('{')) { + this.state = 'json'; + return ''; + } + if (candidate.startsWith('[')) { + const normalized = candidate.replace(/\s/g, ''); + if (normalized === '[') return ''; + if (normalized.startsWith('[{')) { + this.state = 'json'; + return ''; + } + } + + return this.release(); + } + + finish(): string { + if (this.state === 'json') { + if (hasLeakedToolCallTags(this.buffer)) { + this.state = 'leaked'; + this.buffer = ''; + return ''; + } + return this.release(); + } + if (this.state !== 'detecting') return ''; + const candidate = this.buffer.trimStart().toLowerCase(); + if ( + candidate && + PROTOCOL_TAG_PREFIXES.some((prefix) => prefix.startsWith(candidate)) + ) { + this.state = 'leaked'; + this.buffer = ''; + return ''; + } + return this.release(); + } + + private release(): string { + const output = this.buffer; + this.state = 'clean'; + this.buffer = ''; + return output; + } + + get leaked(): boolean { + return this.state === 'leaked'; + } + + get blockingOutput(): boolean { + return this.state !== 'clean'; + } +} + +/** + * Default error text used when a synthesized `functionResponse` has to stand + * in for a real tool result that never made it back into history (e.g. the + * process crashed between the partial-tool_use push and tool completion, or + * the user hit Ctrl+Y before the in-flight tool finished and the scheduler's + * `onAllToolCallsComplete` was a single-shot that already fired into an + * `isResponding` early-return). + */ +export const ORPHAN_TOOL_USE_REPAIR_REASON = + 'Tool execution result was not recorded — likely interrupted by network ' + + 'failure, abort, or process exit. Treat as failure and retry if needed.'; + +/* + * ============================================================================ + * Partial-tool_use repair subsystem — canonical design note. + * ============================================================================ + * + * Every comment block elsewhere in this file that mentions one of the + * concepts below points back here. Per-site comments should be one or two + * lines stating WHAT the local code does; the WHY lives here. + * + * --- The wedge ---------------------------------------------------------- + * + * Anthropic-compatible backends (Anthropic, DeepSeek, …) reject a request + * whose `user[tool_result]` blocks are not at the HEAD of the user message + * immediately following the `model[tool_use]` they answer: + * + * "tool_use_id ... must have a corresponding tool_use block in the + * previous message" + * + * Without a matching pair the session is unrecoverable — `stripOrphanedUser + * EntriesFromHistory` only strips trailing user entries, so a lost tool_use + * cannot be resurrected and the next send 400s repeatedly. + * + * --- The race classes that produce dangling tool_uses -------------------- + * + * Race A (Ctrl+Y mid-flight): user retries before the in-flight tool + * finishes. The scheduler's `onAllToolCallsComplete` is single-shot + * per batch and would otherwise leave the tool stuck in + * `completed-but-not-submitted` forever. + * Race B (process crash / OOM mid-flight): the JSONL transcript captures + * the dangling `model[fc]` and `--resume` rehydrates it. + * Race C (network drop between `content_block_stop` of a tool_use and + * the terminal `message_stop`): `processStreamResponse` re-throws + * after we have already yielded a `functionCall` chunk, so the React + * scheduler is on its way to submit a real `functionResponse` while + * in-memory history has no matching `model[fc]`. + * + * --- The two-layer fix --------------------------------------------------- + * + * (1) Persist the partial assistant turn at the failure point in + * `processStreamResponse` (`this.history.push({role: 'model', parts: + * [...]})` plus the `pendingPartialAssistantTurnIndex` / + * `pendingPartialAssistantRecord` markers) so the matching + * `model[fc]` is on disk and in memory when the late `user[fr]` + * arrives. + * (2) Repair any remaining dangling `model[fc]` whose + * `user[fr]` never landed (`repairOrphanedToolUseTurns`): + * - SYNTHESIZE an `error` fr for ids with no matching response; + * - HOIST the real fr into the immediately-adjacent user turn + * when it landed in a non-adjacent later turn; + * - DROP duplicate fr copies for the same id. + * Then `useLlmStream.handleCompletedTools` dedupes the + * scheduler's late real result against `chat.history` so the + * synthetic and the real result never collide on the wire. + * + * --- Partial-push marker lifecycle --------------------------------------- + * + * Set together on (streamError + hasToolCall + hasContent) inside + * `processStreamResponse`. Cleared together by `popPartialIfPushed` on a + * retryable error rollback, or flushed together to JSONL by the outer + * `finally` after the retry loop exits. Defense-in-depth: every + * history-mutation method (clearHistory / addHistory / setHistory / + * truncateHistory / stripThoughtsFromHistory / + * stripOrphanedUserEntriesFromHistory) resets both markers in lockstep so + * a stale index can't shift onto an unrelated model turn and cause + * `popPartialIfPushed` to splice the wrong entry. Any single-field reset + * is a bug. + * ============================================================================ + */ + +/** + * Walk `history` left-to-right and close every dangling + * tool_use ↔ tool_result pair. For each `model[functionCall]`: + * - SYNTHESIZE an `error` `functionResponse` for ids with no match; + * - HOIST a real fr from a non-adjacent later user turn into the + * adjacent one; + * - drop duplicate fr copies for the same id. + * + * Mutates `history` in place. Returns the synthesized (callId, name) + * pairs so the React scheduler's dedup can drop late real results for + * those ids; hoisted ids are NOT returned (the real fr is still in + * history, scheduler dedup handles them naturally). See the canonical + * note above `ORPHAN_TOOL_USE_REPAIR_REASON`. qwen-code analogue of + * upstream Claude Code's `yieldMissingToolResultBlocks`. + */ +/** Location of a `functionResponse` part within `history`. */ +interface FrLocation { + turnIdx: number; + partIdx: number; + part: Part; +} + +/** + * Output of the scan phase for a single `model[functionCall]` turn at + * `modelIdx`. `expected` maps each `functionCall.id` to its tool name, + * `matched` maps that same id to ALL locations of matching + * `functionResponse` parts across the consecutive user turns that + * follow, and `scanEnd` is one past the last user turn visited. + */ +interface ScanResult { + modelIdx: number; + expected: Map; + matched: Map; + scanEnd: number; + adjacentIdx: number; +} + +/** Decision-phase output: exact mutations the next phase will apply. */ +interface RepairPlan { + modelIdx: number; + scanEnd: number; + adjacentIdx: number; + synthesizeIds: Array<[string, string]>; + hoistedParts: Part[]; + removalTargets: Array<{ turnIdx: number; partIdx: number }>; + droppedDuplicates: Array<{ callId: string; name: string }>; +} + +/** + * SCAN — collect every `functionCall.id → name` from the model turn at + * `modelIdx` and EVERY `functionResponse.id → location` from the + * consecutive user turns that follow. Pure read. Storing all locations + * (not just the first) is what lets the decision phase drop duplicates. + */ +function scanModelTurn(history: Content[], modelIdx: number): ScanResult { + const expected = new Map(); + for (const part of history[modelIdx]?.parts ?? []) { + const fc = part.functionCall; + if (fc?.id) expected.set(fc.id, fc.name ?? 'unknown'); + } + + const matched = new Map(); + let scanIdx = modelIdx + 1; + while ( + scanIdx < history.length && + history[scanIdx]?.role === 'model' && + isDegradedPlaceholderTurn(history[scanIdx]) + ) { + scanIdx++; + } + const adjacentIdx = scanIdx; + while (scanIdx < history.length && history[scanIdx]?.role === 'user') { + const parts = history[scanIdx].parts ?? []; + for (let pIdx = 0; pIdx < parts.length; pIdx++) { + const part = parts[pIdx]; + const id = part.functionResponse?.id; + if (id) { + const list = matched.get(id); + if (list) list.push({ turnIdx: scanIdx, partIdx: pIdx, part }); + else matched.set(id, [{ turnIdx: scanIdx, partIdx: pIdx, part }]); + } + } + scanIdx++; + } + + return { modelIdx, expected, matched, scanEnd: scanIdx, adjacentIdx }; +} + +/** + * DECISION — classify each expected id: no match → SYNTHESIZE; first + * match adjacent → SKIP relocation; first match non-adjacent → HOIST. + * Every duplicate beyond the first is always dropped. Pure compute. + */ +function planRepair(scan: ScanResult): RepairPlan { + const synthesizeIds: Array<[string, string]> = []; + const hoistedParts: Part[] = []; + const removalTargets: Array<{ turnIdx: number; partIdx: number }> = []; + const droppedDuplicates: Array<{ callId: string; name: string }> = []; + + const adjacentIdx = scan.adjacentIdx; + for (const [id, name] of scan.expected) { + const locations = scan.matched.get(id); + if (!locations || locations.length === 0) { + synthesizeIds.push([id, name]); + continue; + } + // First copy is the canonical survivor — payloads should be + // identical for the same callId; if they differ, the wire is + // already corrupt and the backend rejects regardless. + const survivor = locations[0]!; + if (survivor.turnIdx !== adjacentIdx) { + hoistedParts.push(survivor.part); + removalTargets.push({ + turnIdx: survivor.turnIdx, + partIdx: survivor.partIdx, + }); + } + for (let k = 1; k < locations.length; k++) { + removalTargets.push({ + turnIdx: locations[k]!.turnIdx, + partIdx: locations[k]!.partIdx, + }); + droppedDuplicates.push({ callId: id, name }); + } + } + + return { + modelIdx: scan.modelIdx, + scanEnd: scan.scanEnd, + adjacentIdx: scan.adjacentIdx, + synthesizeIds, + hoistedParts, + removalTargets, + droppedDuplicates, + }; +} + +/** + * MUTATION — apply the plan to `history` in place. Returns the count + * of new user turns inserted (0 or 1) so the outer loop can advance its + * cursor. + * + * Order: (1) splice removal targets desc-by-desc, (2) drop empty user + * turns after the resolved adjacent turn, (3) HEAD-insert at that user + * turn OR splice a new user turn there. The HEAD insert is + * load-bearing (mirrors upstream `hoistToolResults`) — see the + * canonical note for why tail-append re-triggers the wedge. + */ +function applyRepair( + history: Content[], + plan: RepairPlan, + reason: string, +): { insertedBefore: number } { + if (plan.synthesizeIds.length === 0 && plan.removalTargets.length === 0) { + return { insertedBefore: 0 }; + } + + const syntheticParts: Part[] = plan.synthesizeIds.map(([callId, name]) => ({ + functionResponse: { id: callId, name, response: { error: reason } }, + })); + const partsToInject: Part[] = [...syntheticParts, ...plan.hoistedParts]; + + // (1) Splice removal targets, descending so indices stay valid. + const removals = [...plan.removalTargets].sort((a, b) => { + if (a.turnIdx !== b.turnIdx) return b.turnIdx - a.turnIdx; + return b.partIdx - a.partIdx; + }); + for (const loc of removals) { + const turnParts = history[loc.turnIdx].parts; + if (turnParts) turnParts.splice(loc.partIdx, 1); + } + + // (2) Drop now-empty user turns after the resolved adjacent turn. + // Preserve the adjacent turn even if empty — we'll rewrite it + // below. + const adjacentIdx = plan.adjacentIdx; + for (let j = plan.scanEnd - 1; j > adjacentIdx; j--) { + if (history[j]?.role === 'user' && (history[j].parts?.length ?? 0) === 0) { + history.splice(j, 1); + } + } + + if (partsToInject.length === 0) return { insertedBefore: 0 }; + + // (3) Place new parts at the head of the adjacent user turn, OR + // insert a fresh user turn at the resolved adjacency. + const next = history[adjacentIdx]; + if (next?.role === 'user') { + const existing = next.parts ?? []; + const firstNonFr = existing.findIndex((part) => !part.functionResponse); + const insertAt = firstNonFr === -1 ? existing.length : firstNonFr; + next.parts = [ + ...existing.slice(0, insertAt), + ...partsToInject, + ...existing.slice(insertAt), + ]; + return { insertedBefore: 0 }; + } + history.splice(adjacentIdx, 0, { role: 'user', parts: partsToInject }); + return { insertedBefore: 1 }; +} + +export interface RepairOrphanedToolUseOptions { + preserveCallIds?: ReadonlySet; +} + +/** + * Forward-walk `history`, planning and applying the repair for each + * `model[functionCall]` turn in turn. Iteration is index-based and the + * cursor advances by the count of user turns inserted ahead of it so + * a freshly-injected turn isn't re-visited. + * + * Splitting scan / decision / mutation into separate functions keeps + * each phase auditable in isolation — index drift can only happen in + * `applyRepair`, the only function that mutates `history`. + */ +export function repairOrphanedToolUseTurns( + history: Content[], + reason: string = ORPHAN_TOOL_USE_REPAIR_REASON, + options?: RepairOrphanedToolUseOptions, +): { + injected: Array<{ callId: string; name: string }>; + droppedDuplicates: Array<{ callId: string; name: string }>; +} { + const injected: Array<{ callId: string; name: string }> = []; + const droppedDuplicates: Array<{ callId: string; name: string }> = []; + const preserveCallIds = options?.preserveCallIds; + + for (let i = 0; i < history.length; i++) { + if (history[i].role !== 'model') continue; + + const scan = scanModelTurn(history, i); + if (scan.expected.size === 0) continue; + + const plan = planRepair(scan); + if (preserveCallIds && preserveCallIds.size > 0) { + plan.synthesizeIds = plan.synthesizeIds.filter( + ([id]) => !preserveCallIds.has(id), + ); + } + if (plan.synthesizeIds.length === 0 && plan.removalTargets.length === 0) { + continue; + } + + const { insertedBefore } = applyRepair(history, plan, reason); + // Only synthesized ids feed `injected` — hoisted ids reference real + // frs that were ALREADY in history before this pass (just + // relocated), so the scheduler's dedup naturally handles them. + for (const [callId, name] of plan.synthesizeIds) { + injected.push({ callId, name }); + } + droppedDuplicates.push(...plan.droppedDuplicates); + // Advance past any freshly-inserted user turn so the outer loop + // doesn't revisit it. Keeps the walk linear-time. + i += insertedBefore; + } + + return { injected, droppedDuplicates }; +} + +/** + * Chat session that enables sending messages to the model with previous + * conversation context. + * + * @remarks + * The session maintains all the turns between user and model. + */ +const SESSION_START_CONTEXT_SENTINEL_START = + ''; +const SESSION_START_CONTEXT_HEADER = 'SessionStart additional context'; + +function buildSessionStartContextBlock(extraInstruction: string): string { + return `\n\n${SESSION_START_CONTEXT_SENTINEL_START}\n${SESSION_START_CONTEXT_HEADER}:\n${extraInstruction}\n${SESSION_START_CONTEXT_SENTINEL_END}`; +} + +function stripTrailingSessionStartContextBlock( + systemInstruction: string, +): string { + const startIndex = systemInstruction.lastIndexOf( + `\n\n${SESSION_START_CONTEXT_SENTINEL_START}\n${SESSION_START_CONTEXT_HEADER}:\n`, + ); + if (startIndex === -1) { + return systemInstruction; + } + + const endIndex = systemInstruction.indexOf( + `\n${SESSION_START_CONTEXT_SENTINEL_END}`, + startIndex, + ); + if (endIndex === -1) { + return systemInstruction; + } + + return systemInstruction.slice(0, startIndex); +} + +export class LlmChat { + // A promise to represent the current state of the message being sent to the + // model. + private sendPromise: Promise = Promise.resolve(); + + /** + * Per-chat last-prompt-token-count, populated from `usageMetadata` on each + * model response. Used by the compaction threshold check so that subagents + * (which intentionally don't write to the global telemetry singleton) can + * still make compaction decisions based on their *own* context size. + */ + private lastPromptTokenCount = 0; + private lastPromptTokenCountIsEstimated = false; + + /** + * Per-chat output-token count from the previous model response. The + * previous response is appended to local history after `promptTokenCount` + * was reported, so steady-state prompt estimates add this value to avoid + * under-counting the next request near the hard compaction threshold. + */ + private lastOutputTokenCount = 0; + + /** + * Route identity (model + auth type + endpoint; see + * Config.getModelRouteIdentity) of the content generator that produced + * the counts above. API-reported sizes are wire-specific: one route's + * count cannot size another route's serialization (#9454). Undefined + * until the first count is recorded. + */ + private tokenCountsRouteKey: string | undefined = undefined; + + /** + * Token counts retained for routes other than the one currently owning + * the slots above, keyed by route identity (#9506). Crossing routes + * retains the current slots here and adopts the target's entry back + * instead of destroying the value: API-reported sizes are per-route + * state that a later turn on the same route still needs — most + * critically the session-token-limit gate, whose keyed read would + * otherwise see 0 after any foreign-route touch between turns. + * Invariant: never holds an entry for {@link tokenCountsRouteKey}. + */ + private readonly tokenCountsByRouteKey = new Map< + string, + { + promptTokenCount: number; + promptTokenCountIsEstimated: boolean; + outputTokenCount: number; + cachedContentTokenCount: number; + } + >(); + + /** + * Number of consecutive auto-compaction failures for this chat. The + * cheap-gate NOOPs once this reaches MAX_CONSECUTIVE_FAILURES (default 3) + * until a successful compress (forced or not) resets it to 0. Replaces the + * single-shot hasFailedCompressionAttempt lock that previously disabled + * auto-compaction for the rest of the session on any failure. + * + * SEMANTICS (R5.3): this counter tracks "non-force, non-hard-rescue + * consecutive failures", NOT every failure literally. + * - Auto-compaction failures (cheap-gate path): increment by 1. + * - Manual `/compress` failures: skipped (`force=true` → `!force` + * guard in the failure branch). + * - Hard-tier rescue failures: skipped here because force=true bypasses + * this breaker; bounded separately by hardRescueFailureCount. + * - Reactive overflow failures: explicitly incremented in the overflow + * handler so N repeated reactive failures still trip this breaker. + * + * If you're debugging "why is hard-rescue firing but the counter is 0", + * that's by design. + */ + private consecutiveFailures = 0; + + /** + * Number of failed hard-tier rescue attempts for this chat. Hard rescue is + * forced and therefore bypasses the cheap-gate breaker, so it needs its own + * bound to avoid spending one compression side-query on every send when + * history repeatedly cannot shrink. NOOP counts toward this bound because + * it leaves the prompt oversized and would otherwise spend one compression + * side-query on every send. COMPRESSED resets this unless the + * post-compression hard-limit guard still rejects the send. + */ + private hardRescueFailureCount = 0; + + /** + * Partial-push markers — index of the in-memory `model[partial fc]` + * and the matching deferred JSONL record. See the canonical note + * above `ORPHAN_TOOL_USE_REPAIR_REASON` for the lifecycle and the + * wedge they prevent. + */ + private pendingPartialAssistantTurnIndex: number | null = null; + private pendingPartialAssistantRecord: + | Parameters[0] + | null = null; + + private readonly imagePayloadStore = new InMemoryImagePayloadStore(); + + /** + * Monotonically counts user-content pushes that survived into history. + * Incremented when `sendMessageStream` pushes the user content and decremented + * only if that same push is rolled back on a setup-time failure. Auto- + * compression mutates history length but never touches this counter, so a + * caller (the Retry strip/restore in client.ts) can snapshot it and tell + * whether the re-submitted content actually landed — a history-length delta + * can't, since compression shrinks history independently of the push. + */ + private userContentPushCount = 0; + private manualPlanExitNoticesEnabled = false; + + /** + * Reset both partial-push markers in lockstep. Every history-mutation + * site uses this — single-field resets are a bug because the fields + * are always paired by lifecycle. + */ + private clearPendingPartialState(): void { + this.pendingPartialAssistantTurnIndex = null; + this.pendingPartialAssistantRecord = null; + } + + private popPendingPartialAssistantTurn(): void { + const idx = this.pendingPartialAssistantTurnIndex; + if (idx === null) return; + if (this.history.length > idx && this.history[idx]?.role === 'model') { + this.history.splice(idx, 1); + } else { + debugLogger.warn( + `[PARTIAL_POP] Splice skipped: idx=${idx}, ` + + `historyLength=${this.history.length}, ` + + `roleAtIdx=${this.history[idx]?.role ?? 'undefined'}`, + ); + } + this.clearPendingPartialState(); + } + + /** + * Creates a new LlmChat instance. + * + * @param config - The configuration object. + * @param generationConfig - Optional generation configuration. + * @param history - Optional initial conversation history. + * @param chatRecordingService - Optional recording service. If provided, chat + * messages will be recorded. + * @param telemetryService - Optional UI telemetry service. When provided, + * prompt token counts are reported on each API response. Pass `undefined` + * for sub-agent chats to avoid overwriting the main agent's context usage. + */ + constructor( + private readonly config: Config, + private readonly generationConfig: GenerateContentConfig = {}, + private history: Content[] = [], + private readonly chatRecordingService?: ChatRecordingService, + private readonly telemetryService?: UiTelemetryService, + ) { + validateHistory(history); + this.redactApprovedPlansFromLoadedHistory(); + } + + enableManualPlanExitNotices(): void { + this.manualPlanExitNoticesEnabled = true; + } + + /** + * Identity of the currently active model route. Optional chaining keeps + * partial Config test mocks (`{} as Config`) from throwing on count + * reads/writes; a missing identity degrades to one stable key, i.e. no + * route-change invalidation. + */ + private currentRouteKey(): string { + return this.config.getModelRouteIdentity?.() ?? ''; + } + + /** + * Make the single-slot token counters describe the route identified by + * `targetRouteKey` (default: the active route). Counts recorded for a + * different route must not anchor admission, output clamping, or + * compression decisions for this one (`/model` switches rebuild the + * content generator but keep this chat instance; #9454). + * + * The crossing is NON-DESTRUCTIVE (#9506): the current slots are + * retained in {@link tokenCountsByRouteKey} under their own route key, + * and the target's retained entry — if any — is adopted back into the + * slots. Zeroing a foreign count outright let any foreign-route touch + * between two turns destroy the value before the session-token-limit + * gate (the only yield site of `SessionTokenLimitExceeded`) could read + * it back keyed by its request route. With retention, a route with no + * counts of its own still falls back to the history-walk estimate + * (slots 0), with reactive overflow recovery as the safety net, while a + * turn returning to a route that has counts reads the exact + * API-reported values. + * + * Defaults to comparing against the ACTIVE route (lazy reads on the + * getters). Send paths pass the route the upcoming request actually + * targets so a foreign count cannot anchor that request's decisions even + * when the active route owns it — e.g. an exact `\0` route selector, or + * a non-exact send whose `model` param overrides the active model. + * + * The telemetry mirror is display-only state: it is resynchronized here + * (adopted or zeroed alongside the slots), so between a `/model` switch + * and the next chat touch the UI counters may briefly show the previous + * route's counts. Decision paths never read the mirror, only the + * route-aware chat getters above. + */ + private adoptTokenCountsForRoute(targetRouteKey?: string): void { + if ( + this.lastPromptTokenCount === 0 && + this.lastOutputTokenCount === 0 && + this.tokenCountsByRouteKey.size === 0 + ) { + return; + } + // Resolve the active-route default only AFTER the zero-count fast path: + // computing a route identity (SHA-256 digest + config lookups) on every + // count read while both counts are 0 (and nothing is retained) would + // defeat the guard above. + targetRouteKey ??= this.currentRouteKey(); + if (this.tokenCountsRouteKey === targetRouteKey) { + return; + } + const retained = this.tokenCountsByRouteKey.get(targetRouteKey); + if (retained) { + this.tokenCountsByRouteKey.delete(targetRouteKey); + this.retainCurrentTokenCounts(); + debugLogger.debug( + `[token-counts] restoring retained counts for route ${targetRouteKey}`, + ); + this.lastPromptTokenCount = retained.promptTokenCount; + this.lastPromptTokenCountIsEstimated = + retained.promptTokenCountIsEstimated; + this.lastOutputTokenCount = retained.outputTokenCount; + this.tokenCountsRouteKey = targetRouteKey; + this.telemetryService?.setLastPromptTokenCount(retained.promptTokenCount); + this.telemetryService?.setLastCachedContentTokenCount( + retained.cachedContentTokenCount, + ); + return; + } + debugLogger.debug( + `[token-counts] route changed; retaining counts recorded for ` + + `${this.tokenCountsRouteKey ?? 'unknown'} (now ${targetRouteKey})`, + ); + this.retainCurrentTokenCounts(); + // Raw assignment on purpose: setLastPromptTokenCount would re-attribute + // the zero slot to the ACTIVE route. The slot is attributed to the + // TARGET route instead so it can never collide with the just-retained + // entry (retained under the evicted slot's key, which differs from the + // target) — a colliding key would make the next keyed read for the + // retained route early-return the zero slot without consulting the map. + this.lastPromptTokenCount = 0; + this.lastPromptTokenCountIsEstimated = false; + this.lastOutputTokenCount = 0; + this.tokenCountsRouteKey = targetRouteKey; + // Keep the telemetry mirror in sync, or the UI context counters + // and compression banners keep reading the foreign count. The cached + // content count belongs to the same foreign route's last response. + this.telemetryService?.setLastPromptTokenCount(0); + this.telemetryService?.setLastCachedContentTokenCount(0); + } + + /** + * Save the current slots into {@link tokenCountsByRouteKey} under their + * owning route key so a later read keyed back to that route restores the + * exact API-reported values. Zero slots carry nothing worth retaining; + * the telemetry mirror still holds the owning route's cached-content + * count at this point, so it is captured here too. + */ + private retainCurrentTokenCounts(): void { + if ( + this.tokenCountsRouteKey === undefined || + (this.lastPromptTokenCount === 0 && this.lastOutputTokenCount === 0) + ) { + return; + } + if (this.tokenCountsByRouteKey.size >= MAX_RETAINED_ROUTE_COUNTS) { + const oldestKey = this.tokenCountsByRouteKey.keys().next().value; + if (oldestKey !== undefined) { + this.tokenCountsByRouteKey.delete(oldestKey); + } + } + this.tokenCountsByRouteKey.set(this.tokenCountsRouteKey, { + promptTokenCount: this.lastPromptTokenCount, + promptTokenCountIsEstimated: this.lastPromptTokenCountIsEstimated, + outputTokenCount: this.lastOutputTokenCount, + // Optional chaining keeps partial telemetry test mocks from throwing + // (same convention as currentRouteKey's Config lookups). + cachedContentTokenCount: + this.telemetryService?.getLastCachedContentTokenCount?.() ?? 0, + }); + } + + /** + * Most recent prompt-token count reported by the model for *this* chat, + * mirroring the value in {@link UiTelemetryService} for the main session. + * Subagent chats have no telemetry service wired but still need a per-chat + * count for compaction decisions, so this is always populated regardless + * of whether the global telemetry is updated. + */ + getLastPromptTokenCount(targetRouteKey?: string): number { + this.adoptTokenCountsForRoute(targetRouteKey); + return this.lastPromptTokenCount; + } + + /** Previous model-response tokens used by the next prompt estimate. */ + getLastOutputTokenCount(): number { + this.adoptTokenCountsForRoute(); + return this.lastOutputTokenCount; + } + + /** + * Builds request contents for the content generator without deep-cloning the + * whole chat history. This is an internal hot path: long sessions can make a + * full `structuredClone` larger than the remaining V8 heap headroom. + * + * Public history readers still use {@link getHistory}, which returns a + * defensive deep copy for caller mutation safety. + */ + private getRequestHistory(currentUserContent?: Content): Content[] { + const curatedHistory = extractCuratedHistory(this.history); + const { maxRecentImages, imagePayloadThreshold } = resolveCompactionTuning( + this.config.getChatCompression(), + ); + let replaced: ReturnType = []; + if (countAllInlineImages(curatedHistory) >= imagePayloadThreshold) { + const skipEntry = currentUserContent + ? curatedHistory.find( + (c) => + c === currentUserContent || + (c.role === 'user' && + currentUserContent.parts?.some((p) => c.parts?.includes(p))), + ) + : undefined; + replaced = replaceImagePayloadsInPlace( + curatedHistory, + this.imagePayloadStore, + skipEntry, + ); + } + const requestHistory = curatedHistory.map(copyContentContainer); + const reattachParts = buildReattachParts( + replaced, + maxRecentImages, + requestHistory, + this.imagePayloadStore, + ); + if (reattachParts.length > 0) { + const last = requestHistory.at(-1); + if (last?.role === 'user') { + last.parts = [...(last.parts ?? []), ...reattachParts]; + } else { + requestHistory.push({ role: 'user', parts: reattachParts }); + } + } + return requestHistory; + } + + private getRequestHistoryForRoute( + currentUserContent: Content | undefined, + supportedModalities: InputModalities, + ): Content[] { + return slimCompactionInput( + this.getRequestHistory(currentUserContent), + supportedModalities, + ).slimmedHistory; + } + + /** + * Seed the last-prompt-token-count for chats created with inherited + * history (forks, subagents, speculation). Without this, the auto-compress + * threshold check sees `0` and refuses to compress — so the first API call + * can 400 from oversized history. Callers pass the parent chat's + * `getLastPromptTokenCount()` here. This also clears any remembered + * previous-response output token count because the seeded prompt count + * comes from a different chat instance and should not inherit this chat's + * last response size. + */ + setLastPromptTokenCount(count: number, isEstimated = false): void { + this.lastPromptTokenCount = count; + this.lastPromptTokenCountIsEstimated = isEstimated; + this.lastOutputTokenCount = 0; + this.tokenCountsRouteKey = this.currentRouteKey(); + // A fresh count supersedes anything this route retained while another + // route owned the slots. Without the delete this writer alone among the + // count writers would leave an entry for tokenCountsRouteKey behind, + // breaking the map's documented invariant (#9506). + this.tokenCountsByRouteKey.delete(this.tokenCountsRouteKey); + } + + isLastPromptTokenCountEstimated(): boolean { + this.adoptTokenCountsForRoute(); + return this.lastPromptTokenCountIsEstimated; + } + + private promptCountIsEstimateDerived(): boolean { + return ( + this.lastPromptTokenCount === 0 || this.lastPromptTokenCountIsEstimated + ); + } + + /** + * Seed the restored prompt and previous-response output token counts in one + * step. Resume restores chat history plus both counters and their provenance + * from the same checkpoint, so callers must avoid the normal + * setLastPromptTokenCount() clearing behavior. + */ + seedResumeTokenCounts( + promptTokenCount: number, + outputTokenCount: number, + isEstimated = false, + ): void { + this.lastPromptTokenCount = Number.isFinite(promptTokenCount) + ? Math.max(0, promptTokenCount) + : 0; + this.lastPromptTokenCountIsEstimated = isEstimated; + this.lastOutputTokenCount = Number.isFinite(outputTokenCount) + ? Math.max(0, outputTokenCount) + : 0; + // Attribute the seeded counts to the active route so a model switch + // after resume invalidates them like any API-reported count. (Detecting + // a route that already differed at save time requires persisting route + // identity in the session transcript; tracked as a follow-up to #9454.) + this.tokenCountsRouteKey = this.currentRouteKey(); + // A fresh seed supersedes any count this route retained while another + // route owned the slots (#9506). + this.tokenCountsByRouteKey.delete(this.tokenCountsRouteKey); + } + + /** + * Attempt to compress this chat's history. + * + * Returns the compression info regardless of outcome. On a successful + * compaction (`COMPRESSED`), this method has already mutated the chat's + * history, recorded the event to `chatRecordingService` (if wired and + * unless `options.deferChatCompressionRecord` is set), and updated both + * the per-chat token count and (when wired) the global telemetry singleton. + * Deferred callers are responsible for recording after their own + * post-compression guards pass. + */ + async tryCompress( + promptId: string, + force = false, + signal?: AbortSignal, + options?: TryCompressOptions, + ): Promise { + // Counts from a pre-switch route must not anchor compression admission + // or sizing for this route (#9454). In-send callers pass the request + // route so the adoption never re-adopts the active route's retained + // counts mid-send (#9506). + this.adoptTokenCountsForRoute(options?.requestRouteKey); + const originalTokenCountIsEstimated = + options?.originalTokenCountOverride === undefined && + this.promptCountIsEstimateDerived(); + const originalTokenCount = originalTokenCountIsEstimated + ? (options?.precomputedEffectiveTokens ?? + estimateContentTokens( + options?.pendingUserMessage + ? [...this.getHistoryShallow(true), options.pendingUserMessage] + : this.getHistoryShallow(true), + resolveSlimmingConfig(this.config.getChatCompression()) + .imageTokenEstimate, + )) + : (options?.originalTokenCountOverride ?? this.lastPromptTokenCount); + debugLogger.debug( + `[compaction] token-count provenance: prompt_id=${promptId}, ` + + `originalTokenCount=${originalTokenCount}, ` + + `estimated=${originalTokenCountIsEstimated}`, + ); + const service = new ChatCompressionService(); + const { newHistory, info } = await service.compress(this, { + promptId, + force, + config: this.config, + consecutiveFailures: this.consecutiveFailures, + originalTokenCount, + pendingUserMessage: options?.pendingUserMessage, + precomputedEffectiveTokens: options?.precomputedEffectiveTokens, + requestGenerationConfig: options?.requestGenerationConfig, + trigger: options?.trigger, + customInstructions: options?.customInstructions, + signal, + }); + + // ChatCompressionService reads the keyless count getters, which adopt + // the ACTIVE route — flipping the slots away from the request route + // adopted above whenever the two differ (non-exact override sends). + // Re-adopt the request route so neither the COMPRESSED stamp below nor + // the caller's post-compression sizing anchors on the flipped + // attribution (#9506). + this.adoptTokenCountsForRoute(options?.requestRouteKey); + + if (info.compressionStatus === CompressionStatus.COMPRESSED && newHistory) { + // ChatCompressionService owns provenance. Keep a conservative fallback + // for older/custom implementations that omit the field, but preserve an + // explicit authoritative `false`. + info.newTokenCountIsEstimated ??= true; + if (!options?.deferChatCompressionRecord) { + this.chatRecordingService?.recordChatCompression({ + info, + compressedHistory: newHistory, + }); + } + this.setHistory(newHistory); + debugLogger.debug('[FILE_READ_CACHE] clear after auto tryCompress'); + this.config.getFileReadCache().clear(); + // Compression rewrote the shared history every retained entry sizes, + // so ALL retained counts are stale — not just the current route's. + // Drop them, or a later keyed read adopts a pre-compression count and + // the session-token-limit gate blocks a prompt that fits the + // compressed history (#9506). + this.tokenCountsByRouteKey.clear(); + this.setLastPromptTokenCount( + info.newTokenCount, + info.newTokenCountIsEstimated, + ); + // setLastPromptTokenCount re-keyed the fresh count to the ACTIVE + // route, but in-send callers compress for the REQUEST route: the + // session-token-limit gate reads by that key (client.ts's sole + // SessionTokenLimitExceeded yield site), and a request that ends + // without a usage report (abort, 400 — the reactive-overflow path + // exists for exactly those) never stamps a count of its own. Re-key + // the fresh count to the request route, retaining it under the + // active key first: the compressed history is shared, so the count + // must anchor BOTH routes' next gate reads (#9506). + if ( + options?.requestRouteKey && + this.tokenCountsRouteKey !== options.requestRouteKey + ) { + this.retainCurrentTokenCounts(); + this.tokenCountsRouteKey = options.requestRouteKey; + // Same invariant as the other count writers: the fresh count + // supersedes anything the request route retained. + this.tokenCountsByRouteKey.delete(options.requestRouteKey); + } + this.telemetryService?.setLastPromptTokenCount(info.newTokenCount); + // Reset the consecutive-failure counter on success so a forced /compress + // (or any successful compaction) recovers a chat whose breaker had + // tripped. + this.consecutiveFailures = 0; + this.hardRescueFailureCount = 0; + } else if (isCompressionFailureStatus(info.compressionStatus)) { + // Track failed attempts (only count if not forced) so we stop spending + // compression-API calls on a chat that can't shrink after + // MAX_CONSECUTIVE_FAILURES strikes in a row. + if (!force) { + this.consecutiveFailures += 1; + if (this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + debugLogger.warn( + `[compaction] circuit breaker tripped after ${this.consecutiveFailures} consecutive failures (cheap-gate path); auto-compaction will NOOP until a successful force compaction resets the counter.`, + ); + } + } + } + + return info; + } + + /** + * Fast, rule-based compression without any LLM side-query. + * + * Force-runs microcompaction (clear old tool results + media, keep recent N) + * then strips thinking parts from all model turns. + */ + compressFast(): { + info: ChatCompressionInfo; + microcompactMeta?: MicrocompactMeta; + } { + // A pre-switch route's count must not anchor fast-compression sizing + // for the active route (#9454). + this.adoptTokenCountsForRoute(); + // Use the same estimator on both sides so the NOOP gate compares + // apples to apples. The API-authoritative lastPromptTokenCount is + // then adjusted by the estimated delta — never replaced wholesale. + const beforeEstimate = estimateContentTokens(this.history); + const projectRoot = this.config.getProjectRoot(); + const targetDir = this.config.getTargetDir?.() ?? projectRoot; + + // Step 1: force microcompaction (clear old tool results + media) + const mcResult = microcompactHistory( + this.history, + null, + this.config.getClearContextOnIdle(), + { + force: true, + preserveReadFileResult: (filePath) => + isManagedMemoryPath(filePath, projectRoot, targetDir), + }, + ); + const mcMeta = mcResult.meta; + + // Step 2: strip thinking parts from model turns + const newHistory = mcResult.history + .map((c) => (c.role === 'model' ? stripThoughtPartsFromContent(c) : c)) + .filter((c): c is Content => c !== null); + + const afterEstimate = estimateContentTokens(newHistory); + + if (afterEstimate >= beforeEstimate) { + const apiBaseline = this.lastPromptTokenCount || beforeEstimate; + return { + info: { + originalTokenCount: apiBaseline, + newTokenCount: apiBaseline, + compressionStatus: CompressionStatus.NOOP, + }, + }; + } + + const reduction = beforeEstimate - afterEstimate; + const apiBaseline = this.lastPromptTokenCount || beforeEstimate; + const baselineIsEstimated = this.promptCountIsEstimateDerived(); + const adjustedTokenCount = Math.max(0, apiBaseline - reduction); + + debugLogger.debug( + `[compaction] fast token-count provenance: ` + + `originalTokenCount=${apiBaseline}, estimated=${baselineIsEstimated}`, + ); + + const info: ChatCompressionInfo = { + originalTokenCount: apiBaseline, + newTokenCount: adjustedTokenCount, + newTokenCountIsEstimated: true, + compressionStatus: CompressionStatus.COMPRESSED, + triggerReason: 'manual', + }; + + this.chatRecordingService?.recordChatCompression({ + info, + compressedHistory: newHistory, + }); + logChatCompression( + this.config, + makeChatCompressionEvent({ + tokens_before: info.originalTokenCount, + tokens_after: info.newTokenCount, + }), + ); + this.setHistory(newHistory); + this.lastPromptTokenCount = adjustedTokenCount; + this.lastPromptTokenCountIsEstimated = true; + this.tokenCountsRouteKey = this.currentRouteKey(); + // Fast compression rewrote the shared history every retained entry + // sizes, so ALL retained counts are stale — the other routes' entries + // describe the same pre-compression history (#9506). + this.tokenCountsByRouteKey.clear(); + this.telemetryService?.setLastPromptTokenCount(adjustedTokenCount); + this.consecutiveFailures = 0; + + return { info, microcompactMeta: mcMeta }; + } + + setSystemInstruction(sysInstr: string) { + this.generationConfig.systemInstruction = sysInstr; + } + + setSessionStartContext(extraInstruction: string) { + const trimmed = extraInstruction.trim(); + if (!trimmed) { + return; + } + + const current = this.generationConfig.systemInstruction; + let baseInstruction = ''; + if (typeof current === 'string') { + baseInstruction = stripTrailingSessionStartContextBlock(current); + } else if (current) { + baseInstruction = getCustomSystemPrompt(current); + baseInstruction = stripTrailingSessionStartContextBlock(baseInstruction); + } + const contextBlock = buildSessionStartContextBlock(trimmed); + this.generationConfig.systemInstruction = `${baseInstruction}${contextBlock}`; + } + + applySessionStartContext( + extraInstruction: string, + _source: SessionStartSource, + ): void { + const trimmed = extraInstruction.trim(); + if (!trimmed) { + return; + } + + this.setSessionStartContext(trimmed); + } + + /** + * Sends a message to the model and returns the response in chunks. + * + * @remarks + * This method will wait for the previous message to be processed before + * sending the next message. + * + * @see {@link Chat#sendMessage} for non-streaming method. + * @param params - parameters for sending the message. + * @return The model's response. + * + * @example + * ```ts + * const chat = ai.chats.create({model: 'gemini-2.0-flash'}); + * const response = await chat.sendMessageStream({ + * message: 'Why is the sky blue?' + * }); + * for await (const chunk of response) { + * console.log(chunk.text); + * } + * ``` + */ + async sendMessageStream( + model: string, + params: SendMessageParameters, + prompt_id: string, + goalContext?: GoalTurnPermit, + options?: LlmChatSendOptions, + ): Promise> { + const turnGoalContext = goalContext ? { ...goalContext } : undefined; + const fullTurnRoute = model.endsWith('\0'); + const exactRoute = fullTurnRoute + ? await this.config + .getBaseLlmClient() + .resolveForModel(model.slice(0, -1), { failClosed: true }) + : undefined; + if (exactRoute) { + model = exactRoute.model; + } + // Both arms are one call: for a non-exact send `exactRoute` is + // undefined, and `resolvedModelIdentity`'s second parameter defaults to + // `getContentGeneratorConfig()` — including when passed an explicit + // undefined. Keeping a single call site means a future change to how + // the request route is identified cannot drift between the arms. + const requestRouteKey = this.config.getModelRouteIdentity( + model, + exactRoute?.contentGeneratorConfig, + ); + // Counts recorded for a route other than this request's target must not + // anchor its admission/clamp/compression decisions (#9454). Comparing + // against the REQUEST route — resolved above — keeps an exact `\0` + // route's decisions off the active route's counts, and a differing + // `model` param gets its own identity instead of borrowing the active + // route's. The crossing retains the current counts under their own + // route key so a later turn back on that route restores them (#9506). + this.adoptTokenCountsForRoute(requestRouteKey); + const requestModalities = + exactRoute?.contentGeneratorConfig.modalities ?? + this.config.getEffectiveInputModalities(); + + await this.sendPromise; + + let streamDoneResolver: () => void; + const streamDonePromise = new Promise((resolve) => { + streamDoneResolver = resolve; + }); + this.sendPromise = streamDonePromise; + + // Clear any partial-push marker left over from a prior unretryable + // break path — the marker is per-send; carrying it across sends + // would let the next send's retry catch wrongly pop a now-valid + // model entry sitting at the stale index. The deferred-record + // stash gets the same per-send reset for the same reason: a + // leftover from a prior unretryable break would otherwise get + // appended to JSONL by THIS send's retry-loop flush, attaching + // someone else's failed turn to this conversation. + this.clearPendingPartialState(); + + let compressionInfo: ChatCompressionInfo; + let requestContents: Content[]; + let userContentAdded = false; + let manualPlanExitNoticeVersion: number | undefined; + let manualPlanExitNoticeText: string | undefined; + + // Determine the ceiling for this turn's output request. The clamp below + // (see clampOutputTokensToWindow) sizes the actual max_tokens to the room + // left in the window, so output can never overflow the context limit and + // compaction thresholds run against the FULL window — no output + // reservation is subtracted (this replaces the #5957/#6266 reservation + // machinery; see the max-tokens-window-clamp design doc). + // + // The ceiling is the explicit user/subagent value when one is set + // (params.config.maxOutputTokens from subagents, samplingParams.max_tokens + // or QWEN_CODE_MAX_OUTPUT_TOKENS from user config), else + // defaultOutputCeiling(model) (the model's output limit clipped to + // OUTPUT_TOKEN_CEILING). + const cgConfigForThresholds = + exactRoute?.contentGeneratorConfig ?? + this.config.getContentGeneratorConfig(); + const parsedEnvMaxTokensForClamp = parsePositiveIntegerEnvValue( + process.env['QWEN_CODE_MAX_OUTPUT_TOKENS'], + ); + const explicitOutputCeiling: number | undefined = + params.config?.maxOutputTokens ?? + cgConfigForThresholds?.samplingParams?.max_tokens ?? + parsedEnvMaxTokensForClamp; + const outputCeiling: number = + explicitOutputCeiling ?? defaultOutputCeiling(model); + // Declared at function level so the MAX_TOKENS escalation path inside + // the generator closure can re-clamp against the same window and prompt + // estimate. + const contextWindowForClamp = + cgConfigForThresholds?.contextWindowSize ?? DEFAULT_TOKEN_LIMIT; + let promptTokensForClamp = 0; + + let currentUserContent: Content | undefined; + try { + // The send-lock above is held but the generator's `finally` (which + // resolves it) has not run yet. Any setup error before returning the + // generator must release the lock or subsequent sends will block forever + // at `await this.sendPromise`. + // Build the user content BEFORE compression so the cheap-gate can size + // the upcoming prompt — closes the "first send after inherited history" + // gap where `lastPromptTokenCount === 0` and the gate would otherwise + // see only the stale prior-turn count (0). + let userContent = createUserContent(params.message); + const toolOutputBudget = this.config.getToolOutputBatchBudget?.(); + if ( + toolOutputBudget !== undefined && + Number.isFinite(toolOutputBudget) && + userContent.parts + ) { + const [guarded] = enforceFunctionResponseBudget( + [ + { + callId: 'send-boundary', + toolName: 'tool-response-batch', + responseParts: userContent.parts, + }, + ], + toolOutputBudget, + ); + if (guarded.responseParts !== userContent.parts) { + debugLogger.warn( + `Tool response send guard reduced an unfinalized batch to ${toolOutputBudget} characters.`, + ); + userContent = { ...userContent, parts: guarded.responseParts }; + } + } + + // Hard-tier rescue: when the estimated prompt size is at or above the + // hard threshold (effectiveWindow - HARD_BUFFER), force compaction in + // this send instead of waiting for the API to reject the request as too + // large. + // + // We compute `effectiveTokens` ONCE here and pass it through to + // tryCompress → service.compress so the cheap-gate doesn't redo the + // estimation (which involves another `getHistory(true)` clone). This + // reuse also fixes a per-config-knob inconsistency: previously the + // hard-tier rescue used the default imageTokenEstimate while the + // cheap-gate inside tryCompress used the user's resolved value. + // (review #4168 R1.3 + R1.4) + // + // The cheap-gate consecutive-failure counter is NOT pre-reset here. + // force=true already bypasses that breaker, while hard-rescue itself is + // bounded by hardRescueFailureCount so persistent pre-send rescue + // failures fall through to reactive overflow after a few strikes. + // Thresholds gate on the full window: the output clamp guarantees the + // response fits, so nothing needs to be pre-reserved for it. + const { hard } = computeThresholds( + contextWindowForClamp, + this.config.getAutoCompactThreshold(), + ); + const imageTokenEstimate = resolveSlimmingConfig( + this.config.getChatCompression(), + ).imageTokenEstimate; + // When lastPromptTokenCount > 0, estimatePromptTokens uses the + // API-authoritative previous prompt count + the previous response's + // output token count + a tiny estimate of just the new user message. + // It does NOT touch the history at all in that branch, so skip the + // costly `getHistory(true)` clone on the steady-state path. + // The lastPromptTokenCount=0 branch (first send after --continue + // restore / subagent inheritance) walks history with a char/4 + // heuristic that can under-count by ~15-20K tokens; the reactive + // overflow recovery path inside the async iterator below (the + // `getContextLengthExceededInfo` → `tryCompress` → RETRY branch) + // is the documented safety net when this under-count causes + // hard-rescue to miss. + const effectiveTokens = estimatePromptTokens( + this.lastPromptTokenCount > 0 ? [] : this.getHistoryShallow(true), + userContent, + this.lastPromptTokenCount, + this.lastOutputTokenCount, + imageTokenEstimate, + ); + const isHardTier = effectiveTokens >= hard; + const shouldForceFromHard = + !exactRoute && + isHardTier && + this.hardRescueFailureCount < MAX_CONSECUTIVE_FAILURES; + const historyBeforeHardRescue = shouldForceFromHard + ? this.getHistoryShallow() + : undefined; + const lastPromptTokenCountBeforeHardRescue = this.lastPromptTokenCount; + const lastPromptTokenCountWasEstimatedBeforeHardRescue = + this.lastPromptTokenCountIsEstimated; + // The rescue's COMPRESSED stamp zeroes lastOutputTokenCount (via + // setLastPromptTokenCount), so the rollback below must restore the + // output half of the resurrected count pair alongside the prompt + // half, or the next turn's additive prompt estimate under-counts by + // the last response's size (#9506). + const lastOutputTokenCountBeforeHardRescue = this.lastOutputTokenCount; + // tryCompress re-stamps tokenCountsRouteKey to the ACTIVE route (via + // setLastPromptTokenCount on the success path) even though this send + // targets the REQUEST route — and hard-rescue only fires for + // non-exact sends, whose request key can differ from the active one. + // Capture the key so the rollback below restores the resurrected + // count's original route attribution along with the count itself. + const tokenCountsRouteKeyBeforeHardRescue = this.tokenCountsRouteKey; + // Snapshot the retention map too: the rescue's compression consumes + // retained entries mid-flight (ChatCompressionService's keyless getter + // reads adopt the active route, deleting-and-consuming its entry) and + // a successful compression clears the map outright. Without the + // snapshot the rollback would restore the slots but not the map, + // leaving the resurrected route's count nowhere (#9506). + const retainedTokenCountsBeforeHardRescue = new Map( + this.tokenCountsByRouteKey, + ); + const hardRescueFailureCountBeforeHardRescue = + this.hardRescueFailureCount; + if (shouldForceFromHard) { + debugLogger.warn( + `[compaction] hard-tier rescue triggered: prompt_id=${prompt_id}, effectiveTokens=${effectiveTokens}, hard=${hard}, hardRescueAttempt=${this.hardRescueFailureCount + 1}, consecutiveFailures=${this.consecutiveFailures}.`, + ); + } else if (isHardTier && !exactRoute) { + debugLogger.warn( + `[compaction] hard-tier rescue skipped after ${this.hardRescueFailureCount} failed attempts; relying on reactive overflow recovery. prompt_id=${prompt_id}, effectiveTokens=${effectiveTokens}, hard=${hard}.`, + ); + } + + if (exactRoute || (isHardTier && !shouldForceFromHard)) { + compressionInfo = { + originalTokenCount: effectiveTokens, + newTokenCount: effectiveTokens, + compressionStatus: CompressionStatus.NOOP, + }; + } else { + compressionInfo = await this.tryCompress( + prompt_id, + shouldForceFromHard, + params.config?.abortSignal, + { + pendingUserMessage: userContent, + precomputedEffectiveTokens: effectiveTokens, + requestGenerationConfig: params.config, + requestRouteKey, + deferChatCompressionRecord: shouldForceFromHard, + // Hard-rescue is force=true to bypass the cheap-gate breaker + // but it remains a semantically AUTOMATIC trigger. Tag the + // compactTrigger explicitly as 'auto' so PostCompact hooks are + // classified correctly while the pending user message preserves + // any active tool-call / response pairing. + trigger: shouldForceFromHard ? 'auto' : undefined, + }, + ); + } + const localPromptTokensAfterCompression = shouldForceFromHard + ? estimatePromptTokens( + this.lastPromptTokenCount > 0 ? [] : this.getHistoryShallow(true), + userContent, + this.lastPromptTokenCount, + this.lastOutputTokenCount, + imageTokenEstimate, + ) + : 0; + if ( + shouldStopAfterHardRescue( + shouldForceFromHard, + hard, + localPromptTokensAfterCompression, + ) + ) { + const message = getHardRescueFailureMessage( + effectiveTokens, + hard, + compressionInfo, + localPromptTokensAfterCompression, + ); + if (shouldForceFromHard) { + this.hardRescueFailureCount = + hardRescueFailureCountBeforeHardRescue + 1; + } + if ( + compressionInfo.compressionStatus === CompressionStatus.COMPRESSED && + historyBeforeHardRescue + ) { + // Hard-rescue compression mutates in-memory history before this + // guard can compare the compressed prompt size. If the compressed + // prompt is still too large to send, restore the pre-compression + // state. The JSONL compression checkpoint is intentionally not + // written because the send is about to be rejected. + this.setHistory(historyBeforeHardRescue); + this.lastPromptTokenCount = lastPromptTokenCountBeforeHardRescue; + this.lastPromptTokenCountIsEstimated = + lastPromptTokenCountWasEstimatedBeforeHardRescue; + this.lastOutputTokenCount = lastOutputTokenCountBeforeHardRescue; + this.tokenCountsRouteKey = tokenCountsRouteKeyBeforeHardRescue; + // Restore the retention map alongside the slots: the rescue's + // compression consumed/cleared entries mid-flight, and without + // the restore the resurrected route's count would survive + // nowhere — its next gate read would pass with 0 (#9506). The + // snapshot predates the rescue, so it already satisfies the + // invariant (no entry for the resurrected slot key). + this.tokenCountsByRouteKey.clear(); + for (const [ + retainedRouteKey, + retainedCounts, + ] of retainedTokenCountsBeforeHardRescue) { + this.tokenCountsByRouteKey.set(retainedRouteKey, retainedCounts); + } + this.telemetryService?.setLastPromptTokenCount( + lastPromptTokenCountBeforeHardRescue, + ); + } + const compressionStatus = + CompressionStatus[compressionInfo.compressionStatus] ?? + String(compressionInfo.compressionStatus); + debugLogger.warn( + `[compaction] hard-tier rescue stopped oversized prompt: ` + + `prompt_id=${prompt_id}, effectiveTokens=${effectiveTokens}, ` + + `hard=${hard}, localPromptTokensAfterCompression=` + + `${localPromptTokensAfterCompression}, compressionStatus=` + + `${compressionStatus}, newTokenCount=` + + `${compressionInfo.newTokenCount}, hardRescueFailureCount=` + + `${this.hardRescueFailureCount}, consecutiveFailures=` + + `${this.consecutiveFailures}. ${message}`, + ); + throw new Error(message); + } + if ( + shouldForceFromHard && + compressionInfo.compressionStatus === CompressionStatus.COMPRESSED + ) { + this.chatRecordingService?.recordChatCompression({ + info: compressionInfo, + compressedHistory: this.getHistoryShallow(), + }); + } + + if (this.manualPlanExitNoticesEnabled) { + const notice = this.config.takePendingManualPlanExitNotice(); + if (notice) { + manualPlanExitNoticeVersion = notice.version; + manualPlanExitNoticeText = getManualPlanExitSystemReminder( + notice.currentMode, + ); + userContent = { + ...userContent, + parts: [ + ...(userContent.parts ?? []), + { + text: manualPlanExitNoticeText, + }, + ], + }; + } + } + + // Add user content to history ONCE before any attempts. + this.history.push(userContent); + currentUserContent = userContent; + userContentAdded = true; + // Record that the user content landed (see `userContentPushCount`). The + // setup-error path below decrements this if it rolls the push back. + this.userContentPushCount++; + // Per-send orphan repair (belt-and-suspenders alongside the + // startChat load-time pass). Runs AFTER user content lands so a + // user-supplied tool_result closes the pair before we synthesize + // anything. An ordinary prompt that races a restore re-hang must + // still close the pair — `model[functionCall] → user[text]` is + // rejected by Anthropic-compatible providers. Restore itself sends + // the real functionResponse, so this pass is a no-op on that path. + const inlineRepair = repairOrphanedToolUseTurns( + this.history, + ORPHAN_TOOL_USE_REPAIR_REASON, + ); + if (inlineRepair.injected.length > 0) { + debugLogger.warn( + `[REPAIR] sendMessageStream inline pass synthesized ` + + `${inlineRepair.injected.length} functionResponse(s): ` + + inlineRepair.injected + .map((entry) => `${entry.name}(${entry.callId})`) + .join(', '), + ); + } + if (inlineRepair.droppedDuplicates.length > 0) { + debugLogger.warn( + `[REPAIR] sendMessageStream inline pass dropped ` + + `${inlineRepair.droppedDuplicates.length} duplicate ` + + `functionResponse(s): ` + + inlineRepair.droppedDuplicates + .map((entry) => `${entry.name}(${entry.callId})`) + .join(', '), + ); + } + requestContents = this.getRequestHistoryForRoute( + currentUserContent, + requestModalities, + ); + + // Window-clamp the output request AFTER compression has settled the + // history: max_tokens = min(ceiling, window − prompt − margin), floored + // at MIN_CLAMPED_OUTPUT_TOKENS. Computed here in the send path — not in + // the shared provider code — so the API-authoritative + // lastPromptTokenCount is in scope and side queries (which set their + // own maxOutputTokens via getBaseLlmClient()) stay exempt by + // construction. This makes `prompt + max_tokens ≤ window` an invariant + // on every main-turn request (issue #5950). + // + // When lastPromptTokenCount > 0 (steady state, or refreshed to + // newTokenCount by compression/resume), re-estimate from the counts — + // cheap, no history walk. When it is still 0, reuse the pre-push gate + // estimate: userContent is already in history here, so a fresh history + // walk would double-count it. Estimate-derived counts can omit the + // system prompt, tool definitions, and skill content (see + // estimatePromptTokens — "typically ~15-20K of under-estimate"). Some + // counts based on prior API usage already preserve part of that + // overhead, but conservatively double-counting it is safe and + // self-corrects when provider usage arrives. An under-count is the ONE + // way `prompt + max_tokens` can still overflow the window, so keep the + // pad until provider usage replaces the estimate. + promptTokensForClamp = + this.lastPromptTokenCount > 0 + ? estimatePromptTokens( + [], + userContent, + this.lastPromptTokenCount, + this.lastOutputTokenCount, + imageTokenEstimate, + /* conservative= */ true, + ) + : effectiveTokens; + if (this.promptCountIsEstimateDerived()) { + promptTokensForClamp += ESTIMATE_CLAMP_OVERHEAD_PAD; + debugLogger.debug( + `[clamp] estimate-derived prompt count; padded by ` + + `${ESTIMATE_CLAMP_OVERHEAD_PAD}: ` + + `promptTokensForClamp=${promptTokensForClamp}, ` + + `count=${this.lastPromptTokenCount}`, + ); + } + const clampedMaxOutputTokens = clampOutputTokensToWindow( + outputCeiling, + contextWindowForClamp, + promptTokensForClamp, + ); + params = { + ...params, + config: { + ...params.config, + maxOutputTokens: clampedMaxOutputTokens, + }, + }; + } catch (error) { + if (userContentAdded) { + this.history.pop(); + // The push above was rolled back, so undo its count too. + this.userContentPushCount--; + } + if (manualPlanExitNoticeVersion !== undefined) { + this.config.restorePendingManualPlanExitNotice( + manualPlanExitNoticeVersion, + ); + } + streamDoneResolver!(); + throw error; + } + + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + return (async function* () { + const sleepInhibitorHandle = acquireSleepInhibitor( + self.config, + 'Qwen Code is streaming a model response', + ); + try { + // Surface a successful auto-compression to the caller as the first + // event in the stream. Failed/skipped compaction attempts are silent. + // Must be inside the try so that a consumer abandoning the stream + // immediately after this event still triggers the finally below; + // otherwise `streamDoneResolver` never fires and the next send hangs. + if ( + compressionInfo.compressionStatus === CompressionStatus.COMPRESSED + ) { + yield { + type: StreamEventType.COMPRESSED, + info: compressionInfo, + }; + } + + let lastError: unknown = new Error('Request failed after all retries.'); + let rateLimitRetryCount = 0; + let transientInvalidStreamRetryCount = 0; + let protocolTagLeakRetryCount = 0; + const totalInvalidStreamRetryCount = () => + transientInvalidStreamRetryCount + protocolTagLeakRetryCount; + // The armed attempt can be rescheduled by a competing retry path + // (rate limit, transport replay/continuation, reactive compression) + // before its outcome is known; the rescheduled attempt is still the + // last one the exhausted invalid-stream budget allows, so keep the + // one-shot quiet-completion acceptance armed for it (#9026). + const rearmQuietAcceptanceIfBudgetSpent = () => { + // Keyed to the transient bucket only: quiet completions surface + // as NO_TOOL_RESULT_PROGRESS (a transient type), so only a spent + // transient budget entitles the next attempt to acceptance. A + // tag-leak-only exhaustion must not arm — a quiet ending still + // has its full retry-first budget ahead of it (#7039). + if ( + transientInvalidStreamRetryCount >= + INVALID_STREAM_RETRY_CONFIG.transientMaxRetries + ) { + acceptQuietToolResultCompletionOnNextAttempt = true; + } + }; + let transportStreamRetryCount = 0; + // Continuation recovery for mid-stream socket closes (issue #7832). + // `transportContinuationText` accumulates every plain-text chunk this + // send has already handed to callers across all continuation attempts, + // so each attempt can show the model its own visible output and ask it + // to resume instead of replaying (which would duplicate that output). + // Attempts are folded in one at a time, each with any overlap it + // replayed stripped, so the buffer holds no fragment twice. + let transportContinuationCount = 0; + let transportContinuationText = ''; + // Text delivered by the attempt currently running, before it is folded + // into `transportContinuationText`. Kept separate so the overlap a + // continuation attempt replays is stripped once, at the attempt + // boundary where it occurs, rather than per chunk — the overlap scan is + // suffix-anchored and would eat legitimately repeated text mid-stream. + let transportAttemptText = ''; + // Text delivered *before* the attempt currently running. Empty unless + // a continuation is in flight. `processStreamResponse` only pushes the + // final attempt's own output to history, so this is what has to be + // prepended once the send succeeds, or the next turn would see the + // model's answer starting mid-sentence. + let transportContinuationPrefix = ''; + let reactiveCompressionAttempted = false; + let suppressNextRetryEvent = false; + let streamYieldedAnyChunk = false; + + // Read per-config overrides; fall back to built-in defaults. + const cgConfig = + exactRoute?.contentGeneratorConfig ?? + self.config.getContentGeneratorConfig(); + const requestOverrides = exactRoute + ? { + contentGenerator: exactRoute.contentGenerator, + retryAuthType: exactRoute.retryAuthType, + retryErrorCodes: exactRoute.retryErrorCodes, + } + : undefined; + const maxRateLimitRetries = + cgConfig?.maxRetries ?? RATE_LIMIT_RETRY_OPTIONS.maxRetries; + const retryInitialDelayMs = + cgConfig?.retryInitialDelayMs ?? + RATE_LIMIT_RETRY_OPTIONS.initialDelayMs; + const retryMaxDelayMs = + cgConfig?.retryMaxDelayMs ?? RATE_LIMIT_RETRY_OPTIONS.maxDelayMs; + const extraRetryErrorCodes = cgConfig?.retryErrorCodes; + + // Max output tokens escalation: when no user/env override is set and + // the model hits MAX_TOKENS, retry once with the escalated limit. + let maxTokensEscalated = false; + const parsedEnvMaxTokens = parsePositiveIntegerEnvValue( + process.env['QWEN_CODE_MAX_OUTPUT_TOKENS'], + ); + const hasUserMaxTokensOverride = + (cgConfig?.samplingParams?.max_tokens !== undefined && + cgConfig?.samplingParams?.max_tokens !== null) || + parsedEnvMaxTokens !== undefined; + // params.config.maxOutputTokens is set by the first-send clamp; the + // outputCeiling fallback is defensive and should not fire in practice. + const effectiveInitialMaxOutputTokens = + params.config?.maxOutputTokens ?? outputCeiling; + const escalatedLimit = clampOutputTokensToWindow( + OUTPUT_TOKEN_CEILING, + contextWindowForClamp, + promptTokensForClamp, + ); + const shouldEscalateMaxOutputTokens = + effectiveInitialMaxOutputTokens < escalatedLimit; + + let lastFinishReason: string | undefined; + + /** + * Contents for the next attempt. Identical to `requestContents` on + * every normal send; while a transport continuation is pending it + * appends the two synthetic turns that carry the delivered output and + * the instruction to resume from it. Built per attempt and never + * written to `self.history`, so the synthetic turns cannot leak into + * durable history, the JSONL transcript, or a later compression — + * unlike the MAX_TOKENS recovery loop, which has to route through + * history and clean up afterwards with `coalesceRecoveryPairs`. + */ + const buildAttemptContents = (): Content[] => + transportContinuationPrefix.length > 0 + ? [ + ...requestContents, + { + role: 'model', + parts: [{ text: transportContinuationPrefix }], + }, + createUserContent([ + { + text: buildRecoveryMessageFromText( + TRANSPORT_CONTINUATION_MESSAGE, + transportContinuationPrefix, + ), + }, + ]), + ] + : requestContents; + + /** + * Forget any in-flight continuation. + * + * Called from every branch that re-sends the *original* request, since + * those emit a `RETRY` without `isContinuation` and the UI drops the + * delivered text on that event. The request has to drop it too, or the + * resend would keep asking the model to resume output the caller no + * longer has — and a later success would merge that discarded text back + * into history, leaving the UI and history permanently out of step. + */ + const resetTransportContinuation = () => { + transportContinuationCount = 0; + transportContinuationText = ''; + transportAttemptText = ''; + transportContinuationPrefix = ''; + }; + + // Fold the running attempt's text into the accumulated buffer, + // stripping any overlap it replayed from the previous attempt's tail, + // so the accumulated buffer never contains text twice. + // + // Called on the cut exit only. The success exit merges the prefix into + // history and breaks, and nothing reads the buffer after the loop, so + // folding there would have no reader. A post-loop read added later + // (telemetry, a MAX_TOKENS-recovery guard) would be missing the final + // attempt's text and must fold on the success path too. + const foldTransportAttemptText = () => { + transportContinuationText += getRecoveryContinuationSuffix( + transportContinuationText, + transportAttemptText, + ); + transportAttemptText = ''; + }; + + let acceptQuietToolResultCompletionOnNextAttempt = false; + for (;;) { + transportAttemptText = ''; + let streamYieldedChunk = false; + let streamYieldedContentChunk = false; + // A cut that already delivered a `functionCall` cannot be continued + // from — see the continuation gate below. + let streamYieldedFunctionCall = false; + try { + if (suppressNextRetryEvent) { + // The branch that scheduled this attempt already emitted its own + // RETRY, and — if that RETRY was a fresh restart rather than a + // continuation — already called `resetTransportContinuation`. + // Resetting again here would clear the state of a continuation + // that is legitimately in flight. + suppressNextRetryEvent = false; + } else if ( + rateLimitRetryCount > 0 || + totalInvalidStreamRetryCount() > 0 || + transportStreamRetryCount > 0 || + transportContinuationCount > 0 + ) { + // A fresh-restart retry reaching this point means a branch that + // does not set `suppressNextRetryEvent` (rate limit, invalid + // stream) chose to re-send the original request. + resetTransportContinuation(); + yield { type: StreamEventType.RETRY }; + } + + const acceptQuietToolResultCompletion = + acceptQuietToolResultCompletionOnNextAttempt; + acceptQuietToolResultCompletionOnNextAttempt = false; + const stream = await self.makeApiCallAndProcessStream( + model, + buildAttemptContents(), + params, + prompt_id, + requestOverrides, + requestRouteKey, + turnGoalContext, + // Captured by value, so the attempt records exactly the prefix + // `buildAttemptContents()` just asked the model to resume from, + // even if a later branch resets the continuation. + transportContinuationPrefix.length > 0 + ? transportContinuationPrefix + : undefined, + acceptQuietToolResultCompletion, + ); + + lastFinishReason = undefined; + for await (const chunk of stream) { + if (hasCandidateOutput(chunk)) { + streamYieldedChunk = true; + streamYieldedAnyChunk = true; + } + if (hasNonThoughtCandidateParts(chunk)) { + streamYieldedContentChunk = true; + } + // Mirror the visible text into the continuation buffer as it is + // yielded. Reading it back off history is not an option on the + // transport path: processStreamResponse deliberately does NOT + // persist a text-only partial turn when the stream throws, so at + // the catch below history holds nothing about what the user + // already saw. + const chunkParts = chunk.candidates?.[0]?.content?.parts; + transportAttemptText += getPlainTextFromParts(chunkParts); + if (chunkParts?.some((part) => part.functionCall)) { + streamYieldedFunctionCall = true; + } + const fr = chunk.candidates?.[0]?.finishReason; + if (fr) lastFinishReason = fr; + yield { type: StreamEventType.CHUNK, value: chunk }; + } + + lastError = null; + // The merge itself now happens inside `processStreamResponse`, + // which folds the prefix into the parts before it writes either + // the JSONL record or the history turn (issue #8094). Merging + // again here would risk double-applying it: the dedup helper only + // strips a replayed prefix that clears its significance floor, so + // a short prefix would survive the second pass and be doubled. + transportContinuationPrefix = ''; + break; + } catch (error) { + lastError = error; + // This attempt is over; fold what it delivered into the running + // buffer before any branch below reads it. Doing this here rather + // than per chunk keeps the overlap scan anchored at the attempt + // boundary, which is the only place a replay can occur. + foldTransportAttemptText(); + + // Handle rate-limit / throttling errors returned as stream content. + // These arrive as StreamContentError with finish_reason="error_finish" + // from the pipeline, containing the throttling message in the content. + // Covers TPM throttling, GLM rate limits, and other provider throttling. + // Classify once per failed attempt; reused by the rate-limit + // diagnostics below and the transport-retry decision further down. + const classification = classifyRetryError(error, { + authType: cgConfig?.authType, + extraRetryErrorCodes, + }); + + // Permanent quota exhaustion (e.g. Bailian token-plan "1-week + // quota has been exhausted, will reset at ...") can arrive + // mid-stream as a StreamContentError, bypassing retryWithBackoff + // (which only wraps stream establishment). Fast-fail before the + // rate-limit branch: its 429 code would otherwise schedule a 1-5 + // minute delay on an error that cannot succeed until the reset + // time. Throws a plain Error (no .status) and skips model + // fallback, matching the retryWithBackoff fast-fail. + if (isQuotaExhaustedError(error)) { + debugLogger.warn('Quota exhausted mid-stream, fast-failing', { + retryPath: 'stream', + retryDecision: 'fail-fast', + errorKind: classification.kind, + classificationReason: classification.reason, + }); + throw new Error(formatQuotaExhaustedMessage(error), { + cause: error, + }); + } + + const isRateLimit = isRateLimitError(error, extraRetryErrorCodes); + if (isRateLimit) { + const details = getRateLimitErrorDetails(error); + // The classifier is observation-only here; stream retry control + // remains governed by isRateLimitError and the retry budget. + const diagnosticFields = { + classificationDiagnosis: classification.diagnosis, + errorKind: classification.kind, + classificationReason: classification.reason, + ...details, + }; + + if (rateLimitRetryCount < maxRateLimitRetries) { + // Discard any partial assistant turn from the failed attempt + // before scheduling the retry, so a stale partial does not leak + // into history or the JSONL transcript. + self.popPendingPartialAssistantTurn(); + rateLimitRetryCount++; + const delayMs = getRateLimitRetryDelayMs(rateLimitRetryCount, { + ...RATE_LIMIT_RETRY_OPTIONS, + initialDelayMs: retryInitialDelayMs, + maxDelayMs: retryMaxDelayMs, + error, + }); + const message = parseAndFormatApiError( + error instanceof Error ? error.message : String(error), + ); + debugLogger.warn('Rate limit retry scheduled', { + retryPath: 'stream', + retryDecision: 'retry', + attempt: rateLimitRetryCount, + maxRetries: maxRateLimitRetries, + retryDelayMs: delayMs, + ...diagnosticFields, + }); + const { promise: delayPromise, skip } = delay( + delayMs, + params.config?.abortSignal, + ); + yield { + type: StreamEventType.RETRY, + retryInfo: { + message, + attempt: rateLimitRetryCount, + maxRetries: maxRateLimitRetries, + delayMs, + skipDelay: skip, + }, + }; + await delayPromise; + rearmQuietAcceptanceIfBudgetSpent(); + continue; + } + + debugLogger.warn('Rate limit retry exhausted', { + retryPath: 'stream', + retryDecision: 'exhausted', + attempts: rateLimitRetryCount, + maxRetries: maxRateLimitRetries, + ...diagnosticFields, + }); + } + + // Replay only curated socket-level failures before any + // user-visible content has reached callers. Thinking-only + // output does not block the replay: thought parts are + // ephemeral (never recorded as the assistant's response in + // history), so retrying after them cannot duplicate visible + // output — and thinking models can spend minutes in that + // phase, exactly when gateways close long-lived SSE + // connections (#7832). + if ( + isRetryableStreamTransportError(classification) && + !streamYieldedContentChunk && + // `streamYieldedContentChunk` is per-attempt, so on its own it + // cannot tell "nothing has been delivered" from "this attempt + // was cut while thinking, after earlier attempts already put + // text on screen". Only the first is replayable; replaying the + // second discards output the caller is watching. The + // accumulated buffer is what distinguishes them, and it must be + // consulted here because this branch is checked before the + // continuation one below. + transportContinuationText.trim().length === 0 && + transportStreamRetryCount < + TRANSPORT_STREAM_RETRY_CONFIG.maxRetries + ) { + self.popPendingPartialAssistantTurn(); + transportStreamRetryCount++; + const delayMs = + TRANSPORT_STREAM_RETRY_CONFIG.initialDelayMs * + transportStreamRetryCount; + debugLogger.warn('Transport stream retry scheduled', { + retryPath: 'stream', + retryDecision: 'retry', + attempt: transportStreamRetryCount, + maxRetries: TRANSPORT_STREAM_RETRY_CONFIG.maxRetries, + retryDelayMs: delayMs, + yieldedNonContentChunks: streamYieldedChunk, + errorKind: classification.kind, + transportCode: classification.transportCode, + }); + yield { type: StreamEventType.RETRY }; + // A replay is a fresh restart, so anything a previous + // continuation had staged must go. The gate above now admits + // only an empty accumulated buffer, which leaves nothing for + // this to clear — it stays as an assertion of that invariant, + // so a future gate change cannot leak staged text into a + // restarted attempt. + resetTransportContinuation(); + suppressNextRetryEvent = true; + await delay(delayMs, params.config?.abortSignal).promise; + rearmQuietAcceptanceIfBudgetSpent(); + continue; + } + // Continuation recovery (issue #7832). Once answer text has been + // delivered, replaying is off the table — it would duplicate what + // the caller already has — but propagating is not the only + // alternative left. Gateways that cap SSE connection lifetime + // (DashScope closes at ~3-5 min) cut long generations partway + // through the answer, which is precisely when the replay gate is + // shut, so large outputs failed outright however many retries were + // configured. Instead of replaying, keep the delivered text and + // ask the model to continue from it — the same shape the MAX_TOKENS + // truncation path already uses: show the model its own partial + // output, inject a resume instruction, and signal the UI with + // `isContinuation` so it keeps its text buffer rather than + // discarding it. + // + // A cut that delivered a `functionCall` is excluded: injecting a + // user turn between a `functionCall` and its `functionResponse` + // produces a sequence providers reject (the same constraint the + // MAX_TOKENS recovery loop enforces via its `hasFunctionCall` + // check), and the scheduler's repair path already covers it. + const canContinueAfterTransportCut = + isRetryableStreamTransportError(classification) && + !streamYieldedFunctionCall && + transportContinuationText.trim().length > 0 && + transportContinuationCount < + TRANSPORT_STREAM_RETRY_CONFIG.maxContinuationRetries; + if (canContinueAfterTransportCut) { + self.popPendingPartialAssistantTurn(); + transportContinuationCount++; + // Everything delivered so far — across earlier continuation + // attempts too, since `transportContinuationText` accumulates + // and is never reset while continuing. Each attempt's own text + // was folded in at the catch above with its replayed overlap + // stripped, so this carries no fragment twice. + transportContinuationPrefix = transportContinuationText; + const delayMs = + TRANSPORT_STREAM_RETRY_CONFIG.initialDelayMs * + transportContinuationCount; + debugLogger.warn('Transport stream continuation scheduled', { + retryPath: 'stream', + retryDecision: 'continue', + attempt: transportContinuationCount, + maxRetries: + TRANSPORT_STREAM_RETRY_CONFIG.maxContinuationRetries, + retryDelayMs: delayMs, + errorKind: classification.kind, + transportCode: classification.transportCode, + deliveredChars: transportContinuationText.length, + }); + // `isContinuation` keeps the UI's text buffer, so the next + // attempt's chunks append to what is already on screen instead + // of replacing it. The delivered text and the resume + // instruction ride along in `buildAttemptContents()`. + yield { type: StreamEventType.RETRY, isContinuation: true }; + suppressNextRetryEvent = true; + await delay(delayMs, params.config?.abortSignal).promise; + rearmQuietAcceptanceIfBudgetSpent(); + continue; + } + if (isRetryableStreamTransportError(classification)) { + // Reached only when neither branch above fired: content was + // already delivered so replaying would duplicate it, or the + // replay budget is exhausted, or continuation is unavailable + // (function-call cut, no text to anchor on, or its own budget + // exhausted). + debugLogger.warn('Transport stream retry not taken', { + retryPath: 'stream', + retryDecision: streamYieldedContentChunk + ? 'skipped_after_content' + : 'exhausted', + attempts: transportStreamRetryCount, + maxRetries: TRANSPORT_STREAM_RETRY_CONFIG.maxRetries, + continuationAttempts: transportContinuationCount, + maxContinuationRetries: + TRANSPORT_STREAM_RETRY_CONFIG.maxContinuationRetries, + errorKind: classification.kind, + transportCode: classification.transportCode, + }); + } + + const contextOverflow = getContextLengthExceededInfo(error); + if (contextOverflow.isExceeded) { + if (!exactRoute && !reactiveCompressionAttempted) { + reactiveCompressionAttempted = true; + const reactiveOriginalTokenCount = + contextOverflow.actualTokens ?? + contextOverflow.limitTokens ?? + cgConfig?.contextWindowSize ?? + DEFAULT_TOKEN_LIMIT; + debugLogger.warn( + 'Context length exceeded; attempting reactive compression.', + ); + try { + const reactiveInfo = await self.tryCompress( + prompt_id, + true, + params.config?.abortSignal, + { + originalTokenCountOverride: reactiveOriginalTokenCount, + precomputedEffectiveTokens: reactiveOriginalTokenCount, + requestGenerationConfig: params.config, + requestRouteKey, + trigger: 'auto', + }, + ); + + if ( + reactiveInfo.compressionStatus === + CompressionStatus.COMPRESSED + ) { + // No-op today: tryCompress's setHistory has already + // cleared the marker. Kept for uniformity with the + // other retry branches in case a future in-place + // tryCompress stops resetting it. + self.popPendingPartialAssistantTurn(); + + // Reactive compression replaces the committed user turn. + // Keep its one-shot notice in the rebuilt retry request. + const noticeText = manualPlanExitNoticeText; + if ( + noticeText && + !self.history.some((content) => + content.parts?.some((part) => + part.text?.includes(noticeText), + ), + ) + ) { + const lastContent = self.history.at(-1); + if (lastContent?.role === 'user') { + lastContent.parts = [ + ...(lastContent.parts ?? []), + { text: noticeText }, + ]; + } else { + self.history.push( + createUserContent([{ text: noticeText }]), + ); + } + } + requestContents = self.getRequestHistoryForRoute( + currentUserContent, + requestModalities, + ); + debugLogger.info( + `Reactive compression succeeded: ` + + `${reactiveInfo.originalTokenCount} -> ` + + `${reactiveInfo.newTokenCount} tokens.`, + ); + yield { + type: StreamEventType.COMPRESSED, + info: reactiveInfo, + }; + yield { type: StreamEventType.RETRY }; + // Compression rebuilt `requestContents` from scratch, so + // any continuation staged against the old contents is + // stale — and the RETRY above already told the UI to drop + // the delivered text. + resetTransportContinuation(); + suppressNextRetryEvent = true; + rearmQuietAcceptanceIfBudgetSpent(); + continue; + } + + debugLogger.warn( + `Reactive compression did not recover context overflow: ` + + `status=${reactiveInfo.compressionStatus}.`, + ); + if ( + isCompressionFailureStatus(reactiveInfo.compressionStatus) + ) { + // Reactive compression is force=true so tryCompress's + // failure branch did not increment the counter. Count it + // explicitly as one strike — a single transient error + // (network blip, model 5xx) should not permanently latch + // the breaker; only repeated reactive failures should. + // The only recovery path for a latched counter is a + // successful compaction (post-call reset at the COMPRESSED + // branch in tryCompress); hard-rescue forwards the counter + // as-is since force=true bypasses the breaker. + self.consecutiveFailures += 1; + if (self.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + debugLogger.warn( + `[compaction] circuit breaker tripped after ${self.consecutiveFailures} consecutive failures (reactive overflow path); auto-compaction will NOOP on the cheap-gate until a successful force compaction resets the counter.`, + ); + } + } + } catch (compressionError) { + if ( + params.config?.abortSignal?.aborted || + isAbortError(compressionError) + ) { + throw compressionError; + } + debugLogger.warn( + 'Reactive compression failed.', + compressionError, + ); + } + } else { + debugLogger.warn( + 'Reactive compression already attempted; ' + + 'propagating the context overflow error to caller.', + ); + } + break; + } + + if ( + error instanceof InvalidStreamError && + (error.type === 'NO_TOOL_RESULT_PROGRESS_MAX_TOKENS' || + (error.type === 'NO_RESPONSE_TEXT' && + lastFinishReason === FinishReason.MAX_TOKENS)) && + !maxTokensEscalated && + !hasUserMaxTokensOverride && + shouldEscalateMaxOutputTokens + ) { + lastError = null; + lastFinishReason = FinishReason.MAX_TOKENS; + break; + } + + // Invalid stream responses use INVALID_STREAM_RETRY_CONFIG, which + // is independent from HTTP retries handled by retryWithBackoff. + const isInvalidStreamError = error instanceof InvalidStreamError; + const maxInvalidStreamRetries = + isInvalidStreamError && error.type === 'PROTOCOL_TAG_LEAK' + ? INVALID_STREAM_RETRY_CONFIG.protocolTagLeakMaxRetries + : INVALID_STREAM_RETRY_CONFIG.transientMaxRetries; + const invalidStreamRetryCount = + isInvalidStreamError && error.type === 'PROTOCOL_TAG_LEAK' + ? protocolTagLeakRetryCount + : transientInvalidStreamRetryCount; + if ( + isInvalidStreamError && + invalidStreamRetryCount < maxInvalidStreamRetries + ) { + self.popPendingPartialAssistantTurn(); + const nextInvalidStreamRetryCount = invalidStreamRetryCount + 1; + if (error.type === 'PROTOCOL_TAG_LEAK') { + protocolTagLeakRetryCount = nextInvalidStreamRetryCount; + } else { + transientInvalidStreamRetryCount = nextInvalidStreamRetryCount; + } + // The armed attempt itself can fail with an invalid-stream + // error and be rescheduled here; rearm so the acceptance is + // not lost across error types (a tag-leak retry scheduled + // after the transient budget is spent must still land armed). + // Transient-keyed, so a tag-leak-only exhaustion never arms + // prematurely (#9026, #7039 retry-first). + rearmQuietAcceptanceIfBudgetSpent(); + const delayMs = + INVALID_STREAM_RETRY_CONFIG.initialDelayMs * + nextInvalidStreamRetryCount; + debugLogger.warn( + `Invalid stream [${(error as InvalidStreamError).type}] ` + + `(retry ${nextInvalidStreamRetryCount}/${maxInvalidStreamRetries}). ` + + `Waiting ${delayMs / 1000}s before retrying...`, + ); + logContentRetry( + self.config, + new ContentRetryEvent( + nextInvalidStreamRetryCount - 1, + (error as InvalidStreamError).type, + delayMs, + model, + ), + ); + yield { type: StreamEventType.RETRY }; + await delay(delayMs, params.config?.abortSignal).promise; + continue; + } + break; + } + } + + // Max output tokens handling: if the retry loop succeeded but hit + // MAX_TOKENS, retry once at an escalated output limit only when that + // would raise the effective initial limit. The escalation target is + // OUTPUT_TOKEN_CEILING, routed through the same window clamp as the + // initial request so the retry itself cannot overflow the window. + // When the initial limit is already at the ceiling (for example the + // clamp was binding), skip the no-op escalation call but still run + // continuation recovery on the partial response. These follow-up + // streams still need the same InvalidStreamError retry guard as the + // main send loop; otherwise a leaked protocol-tag turn would bypass + // the primary rollback/retry path entirely. + const rollbackRecoveryAttempt = () => { + // Pop the partial `model[fc]` FIRST (if processStreamResponse + // pushed one before re-throwing), THEN the recovery user turn. + // Reversed order would strand `OUTPUT_RECOVERY_MESSAGE` as a real + // user turn. Index-checked pop mirrors `popPartialIfPushed` + // above — see the design note above + // `ORPHAN_TOOL_USE_REPAIR_REASON` for the wedge mechanism and + // the partial-push marker lifecycle. + const expectedIdx = self.pendingPartialAssistantTurnIndex; + const lastIdx = self.history.length - 1; + if ( + expectedIdx !== null && + self.history.length > 0 && + self.history[lastIdx]?.role === 'model' + ) { + if (expectedIdx !== lastIdx) { + debugLogger.warn( + `[RECOVERY_POP] Marker/last-index mismatch: ` + + `marker=${expectedIdx}, lastIdx=${lastIdx}, ` + + `historyLength=${self.history.length}. Popping ` + + `last entry as best-effort rollback — investigate ` + + `any history mutation between processStreamResponse's ` + + `partial push and this catch.`, + ); + } + self.history.pop(); + self.clearPendingPartialState(); + } + if ( + self.history.length > 0 && + self.history[self.history.length - 1].role === 'user' + ) { + self.history.pop(); + } + }; + type InvalidStreamRetryEvent = + | Extract + | Extract; + const streamWithInvalidStreamRetries = async function* ( + buildAttempt: () => { + requestContents: Content[]; + params: SendMessageParameters; + rollback: () => void; + }, + retryEvent: Extract = { + type: StreamEventType.RETRY, + }, + ): AsyncGenerator { + let transientRetryCount = 0; + let protocolTagLeakRetryCount = 0; + let acceptQuietToolResultCompletionOnNextAttempt = false; + for (;;) { + const attemptState = buildAttempt(); + try { + const acceptQuietToolResultCompletion = + acceptQuietToolResultCompletionOnNextAttempt; + acceptQuietToolResultCompletionOnNextAttempt = false; + const stream = await self.makeApiCallAndProcessStream( + model, + attemptState.requestContents, + attemptState.params, + prompt_id, + requestOverrides, + requestRouteKey, + turnGoalContext, + undefined, + acceptQuietToolResultCompletion, + ); + for await (const chunk of stream) { + yield { type: StreamEventType.CHUNK, value: chunk }; + } + return; + } catch (error) { + attemptState.rollback(); + if (!(error instanceof InvalidStreamError)) throw error; + + const maxContinuationRetries = + error.type === 'PROTOCOL_TAG_LEAK' + ? INVALID_STREAM_RETRY_CONFIG.protocolTagLeakMaxRetries + : INVALID_STREAM_RETRY_CONFIG.transientMaxRetries; + const continuationRetryCount = + error.type === 'PROTOCOL_TAG_LEAK' + ? protocolTagLeakRetryCount + : transientRetryCount; + if (continuationRetryCount >= maxContinuationRetries) { + throw error; + } + + const nextContinuationRetryCount = continuationRetryCount + 1; + if (error.type === 'PROTOCOL_TAG_LEAK') { + protocolTagLeakRetryCount = nextContinuationRetryCount; + } else { + transientRetryCount = nextContinuationRetryCount; + } + // Same arming rule as the main send loop (#9026): keyed to + // the transient bucket only (quiet completions surface as a + // transient-type error), so a tag-leak-only exhaustion does + // not arm prematurely (#7039 retry-first). + if ( + transientRetryCount >= + INVALID_STREAM_RETRY_CONFIG.transientMaxRetries + ) { + acceptQuietToolResultCompletionOnNextAttempt = true; + } + const delayMs = + INVALID_STREAM_RETRY_CONFIG.initialDelayMs * + nextContinuationRetryCount; + debugLogger.warn( + `Invalid stream [${error.type}] during output continuation ` + + `(retry ${nextContinuationRetryCount}/${maxContinuationRetries}). ` + + `Waiting ${delayMs / 1000}s before retrying...`, + ); + logContentRetry( + self.config, + new ContentRetryEvent( + nextContinuationRetryCount - 1, + error.type, + delayMs, + model, + ), + ); + yield retryEvent; + await delay(delayMs, attemptState.params.config?.abortSignal) + .promise; + } + } + }; + if ( + lastError === null && + lastFinishReason === FinishReason.MAX_TOKENS && + !maxTokensEscalated && + !hasUserMaxTokensOverride + ) { + maxTokensEscalated = true; + let recoveryFinishReason: string | undefined = lastFinishReason; + let recoveryParams: SendMessageParameters = params; + + if (shouldEscalateMaxOutputTokens) { + debugLogger.info( + `Output truncated at ${effectiveInitialMaxOutputTokens} tokens. ` + + `Escalating to ${escalatedLimit} tokens.`, + ); + // Remove partial model response from history + // (processStreamResponse already pushed it) + if ( + self.history.length > 0 && + self.history[self.history.length - 1].role === 'model' + ) { + self.history.pop(); + } + // Signal UI to discard partial output + yield { + type: StreamEventType.RETRY, + maxOutputTokensEscalated: escalatedLimit, + }; + // Retry with escalated max_tokens + const escalatedParams: SendMessageParameters = { + ...params, + config: { + ...params.config, + maxOutputTokens: escalatedLimit, + }, + }; + recoveryParams = escalatedParams; + recoveryFinishReason = undefined; + for await (const event of streamWithInvalidStreamRetries(() => ({ + requestContents, + params: escalatedParams, + rollback: () => self.popPendingPartialAssistantTurn(), + }))) { + if (event.type === StreamEventType.RETRY) { + yield event; + continue; + } + const fr = event.value.candidates?.[0]?.finishReason; + if (fr) recoveryFinishReason = fr; + yield event; + } + } else { + debugLogger.info( + `Output truncated at ${effectiveInitialMaxOutputTokens} tokens; ` + + `skipping no-op escalation to ${escalatedLimit} tokens and running recovery.`, + ); + } + + // Recovery: if the escalated response (or, when escalation is a + // no-op, the initial response) is still truncated, keep the partial + // response in history and inject a recovery message so the model can + // continue from where it left off. + let recoveryCount = 0; + let successfulRecoveries = 0; + while ( + recoveryFinishReason === FinishReason.MAX_TOKENS && + recoveryCount < MAX_OUTPUT_RECOVERY_ATTEMPTS + ) { + // Skip recovery when the truncated turn already contains a + // functionCall. Injecting a plain user message between a + // functionCall and its functionResponse produces an invalid API + // sequence that providers commonly reject. The existing layer-3 + // tool scheduler fallback handles these cases correctly. + const lastEntry = self.history[self.history.length - 1]; + const hasFunctionCall = + lastEntry?.role === 'model' && + lastEntry.parts?.some((p) => p.functionCall) === true; + if (hasFunctionCall) { + debugLogger.info( + 'Skipping recovery: truncated turn contains functionCall; ' + + 'deferring to tool scheduler fallback.', + ); + break; + } + + recoveryCount++; + debugLogger.info( + `Output still truncated after max_tokens handling. ` + + `Recovery attempt ${recoveryCount}/${MAX_OUTPUT_RECOVERY_ATTEMPTS}.`, + ); + // The partial model response is already in history + // (pushed by processStreamResponse). Push a recovery user + // message so the model sees its partial output and continues. + const recoveryUserContent = createUserContent([ + { text: buildOutputRecoveryMessage(lastEntry) }, + ]); + // Signal UI/turn to clear pending (incomplete) tool calls. + // isContinuation tells the UI to keep the text buffer so the + // model's continuation appends to the previous partial output. + yield { type: StreamEventType.RETRY, isContinuation: true }; + recoveryFinishReason = undefined; + + // Re-clamp maxOutputTokens for THIS iteration: the prompt has + // grown by the previous partial response, so the value clamped + // before the first send would overflow the window if reused + // (prompt + stale max_tokens > window). Two independent + // estimates, take the max: + // - Count-based: lastPromptTokenCount/lastOutputTokenCount are + // refreshed from each response's usage metadata — authoritative + // when fresh, but a session-level value: a response that OMITS + // usage mid-recovery leaves it frozen while history keeps + // growing (inconsistent usage reporting from self-hosted + // backends is an anticipated failure class here). + // - Fresh walk of the actual outgoing contents, padded like the + // first send: structurally reflects in-turn growth no matter + // what usage was reported, while the pad covers the + // system/tool overhead a history walk cannot see. + // The max is conservative in the safe direction only: near the + // margin the two roughly agree (walk + pad ≈ authoritative + // count), and whichever went stale or blind is overruled. + const recoveryImageTokenEstimate = resolveSlimmingConfig( + self.config.getChatCompression(), + ).imageTokenEstimate; + const countBasedRecoveryEstimate = + self.lastPromptTokenCount > 0 + ? estimatePromptTokens( + [], + recoveryUserContent, + self.lastPromptTokenCount, + self.lastOutputTokenCount, + recoveryImageTokenEstimate, + /* conservative= */ true, + ) + : 0; + self.history.push(recoveryUserContent); + const recoveryContents = self.getRequestHistoryForRoute( + currentUserContent, + requestModalities, + ); + self.history.pop(); + const walkRecoveryEstimate = + estimateContentTokens( + recoveryContents, + recoveryImageTokenEstimate, + ) + ESTIMATE_CLAMP_OVERHEAD_PAD; + const recoveryPromptEstimate = Math.max( + countBasedRecoveryEstimate, + walkRecoveryEstimate, + ); + // recoveryParams is always `params` or `escalatedParams`, both of + // which have maxOutputTokens set; the `?? outputCeiling` is a + // defensive fallback that never fires in practice. + const recoveryCeiling = + recoveryParams.config?.maxOutputTokens ?? outputCeiling; + const iterationParams: SendMessageParameters = { + ...recoveryParams, + config: { + ...recoveryParams.config, + maxOutputTokens: clampOutputTokensToWindow( + recoveryCeiling, + contextWindowForClamp, + recoveryPromptEstimate, + ), + }, + }; + + try { + for await (const event of streamWithInvalidStreamRetries( + () => { + self.history.push(recoveryUserContent); + return { + requestContents: self.getRequestHistoryForRoute( + currentUserContent, + requestModalities, + ), + params: iterationParams, + rollback: rollbackRecoveryAttempt, + }; + }, + { type: StreamEventType.RETRY, isContinuation: true }, + )) { + if (event.type === StreamEventType.RETRY) { + yield event; + continue; + } + const fr = event.value.candidates?.[0]?.finishReason; + if (fr) recoveryFinishReason = fr; + yield event; + } + // Iteration fully succeeded: both the user recovery turn and + // the model continuation turn are now in history and can be + // coalesced back into the preceding model entry after the loop. + successfulRecoveries++; + } catch (recoveryError) { + rollbackRecoveryAttempt(); + debugLogger.warn( + `Recovery attempt ${recoveryCount} failed: ${recoveryError}`, + ); + // Emit a synthetic finish-reason chunk so the UI gets a + // terminal signal (Finished event) instead of a partial + // response with no end marker. Uses STOP because partial + // chunks from prior successful iterations are already in + // the transcript and represent the user-visible response. + yield { + type: StreamEventType.CHUNK, + value: { + candidates: [ + { + content: { role: 'model', parts: [] }, + finishReason: FinishReason.STOP, + }, + ], + } as unknown as GenerateContentResponse, + }; + break; + } + } + + // Coalesce completed recovery pairs back into the preceding model + // turn so the OUTPUT_RECOVERY_MESSAGE control prompt does not + // persist as a synthetic user turn in durable history. The user + // never sent that message, and leaving it in history would bias + // later turns and pollute compression / replay / export. + if (successfulRecoveries > 0) { + self.coalesceRecoveryPairs(successfulRecoveries); + } + } + + if (lastError) { + if (lastError instanceof InvalidStreamError) { + const totalAttempts = totalInvalidStreamRetryCount() + 1; + logContentRetryFailure( + self.config, + new ContentRetryFailureEvent( + totalAttempts, + lastError.type, + model, + ), + ); + } + + // === Model fallback chain === + // When the primary model's retries exhaust on a capacity/availability + // error, try configured fallback models in sequence. Each fallback + // model gets its own fresh retry budget. + // + // Constraints: + // - Do NOT trigger fallback when persistent mode is active + // (QWEN_CODE_UNATTENDED_RETRY) — persistent mode retries the primary + // model indefinitely by design. + // - Maximum 3 fallback transitions (capped by config normalization). + // - Fallback is only for capacity/availability errors (429/503/529), + // not for auth/billing/client errors. + const fallbackModels = + exactRoute || options?.disableModelFallbacks + ? [] + : self.config.getModelFallbacks(); + + if ( + fallbackModels.length > 0 && + !isUnattendedMode() && + !streamYieldedAnyChunk + ) { + let currentErrorClassification = classifyRetryError(lastError, { + authType: cgConfig?.authType, + extraRetryErrorCodes, + }); + + if (isFallbackEligible(currentErrorClassification)) { + let fallbackSucceeded = false; + let fallbackIndex = 0; + let currentModel = model; + let currentResolvedModel = cgConfig?.model ?? model; + let fallbackStreamYieldedAnyChunk = false; + + for (const fallbackModelId of fallbackModels) { + // Skip fallback models that match the current/primary model + if ( + fallbackModelId === model || + fallbackModelId === currentModel + ) { + debugLogger.warn( + `[FALLBACK] Skipping fallback model "${fallbackModelId}": ` + + `same as current model.`, + ); + continue; + } + + // Resolve the fallback model's content generator + let fallbackGenerator: ContentGenerator; + let fallbackRetryAuthType: string | undefined; + let fallbackRetryErrorCodes: readonly number[] | undefined; + let resolvedFallbackModel: string; + let fallbackModalities: InputModalities | undefined; + try { + const resolved = await self.config + .getBaseLlmClient() + .resolveForModel(fallbackModelId, { failClosed: true }); + fallbackGenerator = resolved.contentGenerator; + fallbackRetryAuthType = resolved.retryAuthType; + fallbackRetryErrorCodes = resolved.retryErrorCodes; + resolvedFallbackModel = resolved.model; + fallbackModalities = + resolved.contentGeneratorConfig?.modalities; + } catch (resolveError) { + if (isAbortError(resolveError)) throw resolveError; + const resolveErrorMessage = + resolveError instanceof Error + ? resolveError.message + : String(resolveError); + debugLogger.warn( + `[FALLBACK] Failed to resolve fallback model ` + + `"${fallbackModelId}": ` + + `${resolveErrorMessage}. ` + + `Trying next fallback.`, + ); + continue; + } + + if (resolvedFallbackModel === currentResolvedModel) { + debugLogger.warn( + `[FALLBACK] Skipping fallback model "${fallbackModelId}": ` + + `resolved model "${resolvedFallbackModel}" matches ` + + `the current model.`, + ); + continue; + } + fallbackIndex++; + + debugLogger.warn( + `[FALLBACK] Model "${currentModel}" exhausted retries ` + + `(reason: ${currentErrorClassification.reason}, ` + + `status: ${currentErrorClassification.statusCode ?? 'unknown'}). ` + + `Switching to fallback model "${fallbackModelId}" ` + + `(${fallbackIndex}/${fallbackModels.length}).`, + ); + + // Emit fallback event so the UI can notify the user + yield { + type: StreamEventType.MODEL_FALLBACK, + info: { + fromModel: currentModel, + toModel: resolvedFallbackModel, + statusCode: currentErrorClassification.statusCode, + fallbackIndex, + }, + }; + + // Remove the partial assistant turn from history before the + // fallback model starts producing its own response. + self.popPendingPartialAssistantTurn(); + + // Run the fallback model through the existing API-call wiring. + let currentFallbackYieldedAnyChunk = false; + try { + const fallbackRequestContents = + self.getRequestHistoryForRoute( + currentUserContent, + fallbackModalities ?? {}, + ); + // Stamp the fallback-served counts under the REQUEST route + // key: a fallback serves on behalf of the same session + // request (the session model never changes), and the + // session-token-limit gate in Client reads the count keyed + // by the request route. Attributing the count to the + // fallback's own route would make every later gate read + // invalidate it, silently disabling the limit for any + // session ever served through fallback (#9454). + for await (const event of self.makeFallbackStream( + resolvedFallbackModel, + fallbackRequestContents, + params, + prompt_id, + fallbackGenerator, + fallbackRetryAuthType, + fallbackRetryErrorCodes, + requestRouteKey, + turnGoalContext, + )) { + const emittedUserVisibleOutput = + event.type !== StreamEventType.CHUNK || + hasCandidateOutput(event.value); + if (emittedUserVisibleOutput) { + currentFallbackYieldedAnyChunk = true; + fallbackStreamYieldedAnyChunk = true; + } + yield event; + } + + // Fallback succeeded + lastError = null; + fallbackSucceeded = true; + debugLogger.info( + `[FALLBACK] Successfully completed request with ` + + `fallback model "${resolvedFallbackModel}".`, + ); + return; + } catch (fallbackError) { + if (isAbortError(fallbackError)) throw fallbackError; + lastError = fallbackError; + + if (currentFallbackYieldedAnyChunk) { + self.popPendingPartialAssistantTurn(); + debugLogger.warn( + `[FALLBACK] Fallback model "${resolvedFallbackModel}" ` + + `failed after emitting output. Popped the partial ` + + `assistant turn and stopped the fallback chain to ` + + `avoid duplicating user-visible output.`, + ); + break; + } + + // Classify the fallback error to decide whether to continue + // to the next fallback or give up + const fallbackClassification = classifyRetryError( + fallbackError, + { + authType: fallbackRetryAuthType, + extraRetryErrorCodes: fallbackRetryErrorCodes, + }, + ); + + const canTryNextFallback = isFallbackEligible( + fallbackClassification, + ); + debugLogger.warn( + `[FALLBACK] Fallback model "${resolvedFallbackModel}" also ` + + `failed (reason: ${fallbackClassification.reason}, ` + + `status: ${fallbackClassification.statusCode ?? 'unknown'}). ` + + `${canTryNextFallback ? 'Checking remaining fallbacks.' : 'Stopping fallback chain.'}`, + ); + + currentModel = resolvedFallbackModel; + currentResolvedModel = resolvedFallbackModel; + currentErrorClassification = fallbackClassification; + + // Only continue to next fallback if this error is also + // fallback-eligible. Auth/client errors should fail immediately. + if (!canTryNextFallback) { + debugLogger.warn( + `[FALLBACK] Error from "${resolvedFallbackModel}" is not ` + + `fallback-eligible (${fallbackClassification.reason}). ` + + `Stopping fallback chain.`, + ); + break; + } + } + } + + if (!fallbackSucceeded) { + if (!fallbackStreamYieldedAnyChunk) { + self.popPendingPartialAssistantTurn(); + } + debugLogger.warn( + '[FALLBACK] Fallback chain exhausted without success. ' + + 'Throwing last error.', + ); + } + } else { + debugLogger.warn( + '[FALLBACK] Fallback chain skipped: primary error is not ' + + 'fallback-eligible ' + + `(reason: ${currentErrorClassification.reason}, ` + + `diagnosis: ${currentErrorClassification.diagnosis}, ` + + `status: ${currentErrorClassification.statusCode ?? 'unknown'}, ` + + `error: ${lastError instanceof Error ? lastError.message : String(lastError)}).`, + ); + } + } else if ( + fallbackModels.length > 0 && + !isUnattendedMode() && + streamYieldedAnyChunk + ) { + debugLogger.warn( + '[FALLBACK] Fallback chain skipped because the primary model ' + + 'already emitted user-visible output.', + ); + } + + if (lastError) { + throw lastError; + } + } + } finally { + sleepInhibitorHandle.release(); + streamDoneResolver!(); + // Flush any deferred partial-tool_use record. Covers both the + // post-retry-loop unretryable break AND the max-tokens + // escalation throw (the escalated processStreamResponse can + // set a new record that escapes the retry-loop catch). + // Recording-service errors are logged at error level (sustained + // failure = monitoring signal) and swallowed — propagating + // would mask the real send outcome. + if (self.pendingPartialAssistantRecord) { + try { + self.chatRecordingService?.recordAssistantTurn( + self.pendingPartialAssistantRecord, + ); + } catch (recordErr) { + debugLogger.error( + '[PARTIAL_FLUSH] Failed to persist deferred JSONL record: ' + + (recordErr instanceof Error + ? recordErr.message + : String(recordErr)), + ); + } + self.clearPendingPartialState(); + } + } + })(); + } + + /** + * Makes an API call with retry logic and returns the processed stream. + * + * When called without `overrides`, uses the session's primary content + * generator and provider config (the common path for the main model). + * Pass `overrides` to run against a different content generator — used + * by the fallback chain to call alternative models without duplicating + * the retry wiring. + */ + private async makeApiCallAndProcessStream( + model: string, + requestContents: Content[], + params: SendMessageParameters, + prompt_id: string, + overrides?: { + contentGenerator: ContentGenerator; + retryAuthType?: string; + retryErrorCodes?: readonly number[]; + }, + routeKey = this.currentRouteKey(), + goalContext?: GoalTurnPermit, + transportContinuationPrefix?: string, + acceptQuietToolResultCompletion = false, + ): Promise> { + const generator = + overrides?.contentGenerator ?? this.config.getContentGenerator(); + const apiCall = () => + generator.generateContentStream( + { + model, + contents: requestContents, + config: { ...this.generationConfig, ...params.config }, + }, + prompt_id, + ); + const cgConfig = this.config.getContentGeneratorConfig(); + const authType = overrides?.retryAuthType ?? cgConfig?.authType; + const extraRetryErrorCodes = + overrides?.retryErrorCodes ?? cgConfig?.retryErrorCodes; + // Fallback models never enter persistent retry mode — persistent mode + // is the caller's explicit opt-in for the primary model only. + const persistentMode = overrides ? false : isUnattendedMode(); + const streamResponse = await retryWithBackoff(apiCall, { + shouldRetryOnError: (error: unknown) => { + if (error instanceof Error) { + if (isSchemaDepthError(error.message)) return false; + if (isInvalidArgumentError(error.message)) return false; + } + + const status = getErrorStatus(error); + if (status === 400) return false; + if (status === 429) return true; + if (status && status >= 500 && status < 600) return true; + + // Honor provider-specific rate-limit codes (e.g. DashScope) so a custom + // predicate does not silently drop them — the default path checks these + // via defaultShouldRetry, but a custom shouldRetryOnError bypasses it. + if (isRateLimitError(error, extraRetryErrorCodes)) return true; + + // Transient network errors (ECONNRESET, ETIMEDOUT, etc.) carry no HTTP + // status and would otherwise fall through every predicate above. + if ( + classifyRetryError(error, { extraRetryErrorCodes }).kind === + 'transport' + ) { + return true; + } + + return false; + }, + authType, + extraRetryErrorCodes, + persistentMode, + signal: params.config?.abortSignal, + ...(persistentMode + ? { + heartbeatFn: (info: HeartbeatInfo) => { + process.stderr.write( + `[qwen-code] Waiting for API capacity... attempt ${info.attempt}, retry in ${Math.ceil(info.remainingMs / 1000)}s\n`, + ); + }, + } + : {}), + onRetry: (info) => { + logApiRetry( + this.config, + new ApiRetryEvent({ + model, + promptId: prompt_id, + attemptNumber: info.attempt, + error: info.error, + statusCode: info.errorStatus, + retryDelayMs: info.delayMs, + subagentName: subagentNameContext.getStore(), + }), + ); + }, + }); + + return this.processStreamResponse( + model, + rejectDegradedPlaceholderResponse(streamResponse), + routeKey, + goalContext, + transportContinuationPrefix, + acceptQuietToolResultCompletion, + ); + } + + private async *makeFallbackStream( + model: string, + requestContents: Content[], + params: SendMessageParameters, + prompt_id: string, + contentGenerator: ContentGenerator, + retryAuthType?: string, + retryErrorCodes?: readonly number[], + routeKey?: string, + goalContext?: GoalTurnPermit, + ): AsyncGenerator { + const stream = await this.makeApiCallAndProcessStream( + model, + requestContents, + params, + prompt_id, + { contentGenerator, retryAuthType, retryErrorCodes }, + routeKey, + goalContext, + ); + + for await (const chunk of stream) { + yield { type: StreamEventType.CHUNK, value: chunk }; + } + } + + /** + * Returns the chat history. + * + * @remarks + * The history is a list of contents alternating between user and model. + * + * There are two types of history: + * - The `curated history` contains only the valid turns between user and + * model, which will be included in the subsequent requests sent to the model. + * - The `comprehensive history` contains all turns, including invalid or + * empty model outputs, providing a complete record of the history. + * + * The history is updated after receiving the response from the model, + * for streaming response, it means receiving the last chunk of the response. + * + * The `comprehensive history` is returned by default. To get the `curated + * history`, set the `curated` parameter to `true`. + * + * @param curated - whether to return the curated history or the comprehensive + * history. + * @return History contents alternating between user and model for the entire + * chat session. + */ + getHistory(curated: boolean = false): Content[] { + const history = curated + ? extractCuratedHistory(this.history) + : this.history; + // Deep copy the history to avoid mutating the history outside of the + // chat session. + return structuredClone(history); + } + + /** + * Returns a deep-copied tail of the chat history. This avoids cloning the + * entire session when callers only need recent context. + */ + getHistoryTail(count: number, curated: boolean = false): Content[] { + if (count <= 0) return []; + const history = curated + ? extractCuratedHistory(this.history) + : this.history; + return structuredClone(history.slice(-count)); + } + + /** + * Copies history containers, Part objects, and nested functionResponse parts + * without cloning large leaf payloads. Consumers must not mutate leaf + * payload objects. + */ + getHistoryShallow(curated: boolean = false): Content[] { + const history = curated + ? extractCuratedHistory(this.history) + : this.history; + return history.map(copyContentContainer); + } + + getHistoryForForkWindow(): Content[] { + const history = this.history.slice(getStartupContextLength(this.history)); + return extractCuratedHistory(history).map(copyContentContainer); + } + + /** + * Shallow tail variant for hot paths that only need recent history. + */ + getHistoryTailShallow(count: number, curated: boolean = false): Content[] { + if (count <= 0) return []; + const history = curated + ? extractCuratedHistory(this.history) + : this.history; + return history.slice(-count).map(copyContentContainer); + } + + /** + * Returns a defensive copy of the last raw history entry without cloning the + * full conversation. This avoids O(history) cloning, though cloning the last + * entry is still proportional to that entry's own size. + */ + getLastHistoryEntry(): Content | undefined { + return this.getHistoryTail(1)[0]; + } + + /** + * Returns the last raw history entry for read-only checks. Callers must not + * mutate the returned object. + */ + peekLastHistoryEntry(): Content | undefined { + return this.history.at(-1); + } + + /** + * Returns concatenated text from the last model entry without cloning the + * full history. Used by stop hooks, where only the latest assistant text is + * needed. + */ + getLastModelMessageText(): string | undefined { + for (let i = this.history.length - 1; i >= 0; i--) { + const message = this.history[i]; + if (message?.role !== 'model') continue; + const text = + message.parts + ?.filter( + (part): part is { text: string } => + typeof part.text === 'string' && !part.thought, + ) + .map((part) => part.text) + .join('') ?? ''; + return text || undefined; + } + return undefined; + } + + /** + * Returns the number of entries in the raw chat history. O(1) and + * does not clone — use this when you only need the count and would + * otherwise pay the {@link getHistory} `structuredClone` cost. + */ + getHistoryLength(): number { + return this.history.length; + } + + /** + * Monotonic count of user-content pushes that survived into history (see the + * field doc). Snapshot it before a send and compare after to tell whether the + * send actually pushed the user content — robust to auto-compression, which + * changes history length without touching this counter. + */ + getUserContentPushCount(): number { + return this.userContentPushCount; + } + + /** + * Set of `functionResponse.id` strings in user turns. Walk-only, + * no clone — `useLlmStream.handleCompletedTools` calls this per + * tool-completion batch, so {@link getHistory}'s `structuredClone` + * would stall the UI on long sessions. + */ + getHistoryFunctionResponseIds(): Set { + const ids = new Set(); + for (const entry of this.history) { + if (entry.role !== 'user') continue; + for (const part of entry.parts ?? []) { + const id = part.functionResponse?.id; + if (id) ids.add(id); + } + } + return ids; + } + + /** + * Map of handled tool-call id → (name, args) fingerprint for duplicate + * provider-id replay detection: model-turn `functionCall`s whose id has a + * matching user-turn `functionResponse`. Walk-only, no clone, same + * rationale as {@link getHistoryFunctionResponseIds}; fingerprints of + * large args are cached per part object (see getFunctionCallFingerprint). + */ + getHistoryToolCallFingerprints(): Map { + const fingerprintsById = new Map(); + const respondedIds = new Set(); + for (const entry of this.history) { + if (entry.role === 'user') { + for (const part of entry.parts ?? []) { + const id = part.functionResponse?.id; + if (id) respondedIds.add(id); + } + continue; + } + for (const part of entry.parts ?? []) { + const functionCall = part.functionCall; + if (functionCall?.id && !fingerprintsById.has(functionCall.id)) { + fingerprintsById.set( + functionCall.id, + getFunctionCallFingerprint(functionCall), + ); + } + } + } + const handled = new Map(); + for (const id of respondedIds) { + const fingerprint = fingerprintsById.get(id); + if (fingerprint !== undefined) handled.set(id, fingerprint); + } + return handled; + } + + /** + * Clears the chat history. + */ + clearHistory(): void { + this.history = []; + // Any pending partial-push state points into the now-empty history; + // resetting prevents `popPartialIfPushed` from splicing whatever + // shows up at that index in a future send (defense-in-depth — the + // helper also bounds-checks, but a stale marker that happens to + // line up with a real model turn could otherwise pop the wrong + // entry). The deferred-record stash is dropped for the same reason: + // a later flush would append a turn that doesn't match the (now- + // empty) live history. + this.clearPendingPartialState(); + } + + /** + * Adds a new entry to the chat history. + */ + addHistory(content: Content): void { + this.history.push(content); + // addHistory only runs between sends, so the partial-push marker + // should already be cleared. If it is not, a new caller is + // violating that invariant — surface it at error level so the + // offending stack is visible. See the design note above + // `ORPHAN_TOOL_USE_REPAIR_REASON` for the marker lifecycle. + if ( + this.pendingPartialAssistantTurnIndex !== null || + this.pendingPartialAssistantRecord !== null + ) { + debugLogger.error( + '[INVARIANT_VIOLATION] addHistory called while a partial-push ' + + 'marker is active — clearing it.', + ); + } + this.clearPendingPartialState(); + } + + /** + * Replaces the `plan` argument of an `exit_plan_mode` `functionCall` in + * history with a short reference, keeping every other part and argument + * intact. + * + * The full plan text a model submits to `exit_plan_mode` stays in history + * as its own tool-call arguments; on long conversations models + * occasionally regurgitate chunks of that blob in later responses + * (#6237). Once the plan is approved it is persisted to disk by + * `Config.savePlan`, so the in-context copy can be swapped for a pointer + * without losing information. Rejected plans are left untouched — the + * model needs the text to revise them. + * + * When `expectedPlan` is provided the rewrite additionally requires the + * in-history plan to equal it byte-for-byte. Callers pass the on-disk + * plan-file content here so the pointer can never claim a save that + * failed (`savePlanBestEffort` swallows filesystem errors) or reference + * a file that holds a different plan. + * + * The entry is replaced immutably at the same index; the partial-push + * markers compare by index and role, so this cannot desync them. + * + * @returns true when a matching functionCall was found and rewritten. + */ + redactApprovedPlanFromHistory( + callId: string, + replacement: string, + expectedPlan?: string, + ): boolean { + for (let i = this.history.length - 1; i >= 0; i--) { + const entry = this.history[i]; + if (entry?.role !== 'model' || !entry.parts) continue; + const partIdx = entry.parts.findIndex( + (part) => + part.functionCall?.id === callId && + canonicalPlanToolName(part.functionCall.name) === + ToolNames.EXIT_PLAN_MODE, + ); + if (partIdx === -1) continue; + const part = entry.parts[partIdx]!; + const functionCall = part.functionCall!; + const plan = (functionCall.args ?? {})['plan']; + if (typeof plan !== 'string') { + return false; + } + if (expectedPlan !== undefined && plan !== expectedPlan) { + return false; + } + const newParts = [...entry.parts]; + newParts[partIdx] = { + ...part, + functionCall: { + ...functionCall, + args: { ...functionCall.args, plan: replacement }, + }, + }; + this.history[i] = { ...entry, parts: newParts }; + return true; + } + return false; + } + + /** + * Read-side counterpart of {@link redactApprovedPlanFromHistory}: the + * chat-recording JSONL captured the assistant turn (with the full plan + * argument) before the tool ran, so a `--resume` / `--continue` reload + * re-feeds the plan text the in-session redaction already removed + * (#6237). Every wholesale history load re-applies the redaction to + * approved `exit_plan_mode` calls. + * + * Only calls whose in-history plan matches the current on-disk plan file + * are rewritten — same never-lie rule as the write side. With several + * approved plans in one session the file holds only the last one, so + * earlier calls rehydrate unredacted; safe, just not minimal. + */ + private redactApprovedPlansFromLoadedHistory(): void { + const hasPlanCall = this.history.some((entry) => + entry?.parts?.some( + (part) => + canonicalPlanToolName(part.functionCall?.name) === + ToolNames.EXIT_PLAN_MODE, + ), + ); + if (!hasPlanCall) return; + let planPath: string; + let savedPlan: string; + try { + planPath = this.config.getPlanFilePath(); + savedPlan = fs.readFileSync(planPath, 'utf-8'); + } catch (err) { + // No plan file (never saved, or save failed): leave history alone — + // never swap plan text for a pointer to a file that is not there. + // Logged (unlike a bare swallow) so a --resume that silently skips + // the redaction is traceable under DEBUG. + debugLogger.debug( + `Skipping load-side plan redaction, plan file unavailable: ${err}`, + ); + return; + } + const redacted = redactApprovedPlansInHistory( + this.history, + savedPlan, + planPath, + ); + if (redacted) { + this.history = redacted; + } else { + // hasPlanCall was true, so a null here means every exit_plan_mode + // call was skipped (unapproved, id-less, or plan text differing + // from the saved file) — trace it for "plan still in history" + // triage, mirroring the write side. + debugLogger.debug( + `Load-side plan redaction left history unchanged: no approved ` + + `exit_plan_mode call matches the plan file at ${planPath}.`, + ); + } + } + + setHistory(history: Content[]): void { + this.history = history; + // History replacement (compression, /clear, --resume reload) wipes + // the index basis the partial-push marker was captured against. The + // marker MUST be cleared — otherwise `popPartialIfPushed` could find + // a model turn at the stale index in the replacement history and + // splice an entry that has nothing to do with the original partial + // push, corrupting the conversation. Drop the paired deferred-record + // stash too: its referent (the model turn at the old index) is gone. + this.clearPendingPartialState(); + this.redactApprovedPlansFromLoadedHistory(); + } + + truncateHistory(keepCount: number): void { + this.history = this.history.slice(0, keepCount); + // Truncation can drop the entry the partial-push marker points at, + // or leave it valid but shift the meaning of nearby indices. Reset + // both fields rather than try to fix them up — they're per-send and + // ephemeral, so losing them across a truncate is safe (the + // sendMessageStream that pushed them has already finished or will + // start fresh on the next call). + this.clearPendingPartialState(); + } + + stripThoughtsFromHistory(): void { + this.history = this.history + .map(stripThoughtPartsFromContent) + .filter((content): content is Content => content !== null); + // Filter+map replaces `this.history` with a new array, so any pending + // partial-push marker is now indexed against an array that no longer + // exists. Clear it for the same reason setHistory does — and drop + // the paired deferred-record stash so a later flush can't land a + // turn that doesn't exist in live history. + this.clearPendingPartialState(); + } + + /** + * Pop orphaned trailing user entries from chat history. + * In a valid conversation the last entry is always a model response; + * any trailing user entries are leftovers from a request that failed. + */ + stripOrphanedUserEntriesFromHistory(): Content[] { + const strippedEntries: Content[] = []; + while ( + this.history.length > 0 && + this.history[this.history.length - 1]!.role === 'user' + ) { + // Never pop a *pure* system-reminder user entry. These are structural, + // not orphaned turns: the startup-context prelude (history[0]) and + // mid-history MCP added-tool reminders injected by + // drainPendingAddedMcpToolsReminder. Popping the latter would lose the + // announcement permanently — pendingAddedMcpTools is already cleared and + // the tool name is already in announcedDeferredToolNames, so + // queueAddedMcpToolsReminder won't re-queue it. + // + // Must check EVERY part, not just parts[0]: a failed user turn in plan + // mode (or with subagent/memory reminders) is recorded as one Content + // whose parts are […, actual prompt]. Matching parts[0] + // alone would treat that as structural and preserve the user's prompt + // text, which then leaks into the next turn via appendCuratedContent. + const lastEntry = this.history[this.history.length - 1]; + if (lastEntry && isSystemReminderContent(lastEntry)) { + break; + } + strippedEntries.unshift(this.history.pop()!); + } + // Today this is safe even without the reset — only trailing user + // entries are popped, which can't shift the index of an earlier + // `model` partial. But every other history-mutation method now + // clears the partial-push state in lockstep + // (clearHistory/addHistory/setHistory/truncateHistory/ + // stripThoughtsFromHistory), so omitting it here would be a silent + // exception to the uniform invariant: a future caller invoking + // this method between the deferred JSONL flush and the next + // `sendMessageStream` would otherwise leave a stale marker that + // happens to line up with whatever model entry is at that index + // in the meanwhile. + this.clearPendingPartialState(); + return strippedEntries; + } + + /** + * Instance wrapper around the free-function {@link repairOrphanedToolUseTurns}. + * See the canonical note above `ORPHAN_TOOL_USE_REPAIR_REASON`. + */ + repairOrphanedToolUseTurns( + reason?: string, + options?: RepairOrphanedToolUseOptions, + ): { + injected: Array<{ callId: string; name: string }>; + droppedDuplicates: Array<{ callId: string; name: string }>; + } { + return repairOrphanedToolUseTurns(this.history, reason, options); + } + + setTools(tools: Tool[]): void { + this.generationConfig.tools = tools; + } + + /** Returns a shallow copy of the current generation config (for cache param snapshots). */ + getGenerationConfig(): GenerateContentConfig { + return { ...this.generationConfig }; + } + + async maybeIncludeSchemaDepthContext(error: StructuredError): Promise { + // Check for potentially problematic cyclic tools with cyclic schemas + // and include a recommendation to remove potentially problematic tools. + if ( + isSchemaDepthError(error.message) || + isInvalidArgumentError(error.message) + ) { + const toolRegistry = this.config.getToolRegistry(); + await toolRegistry.warmAll(); + const tools = toolRegistry.getAllTools(); + const cyclicSchemaTools: string[] = []; + for (const tool of tools) { + if ( + (tool.schema.parametersJsonSchema && + hasCycleInSchema(tool.schema.parametersJsonSchema)) || + (tool.schema.parameters && hasCycleInSchema(tool.schema.parameters)) + ) { + cyclicSchemaTools.push(tool.displayName); + } + } + if (cyclicSchemaTools.length > 0) { + const extraDetails = + `\n\nThis error was probably caused by cyclic schema references in one of the following tools, try disabling them with excludeTools:\n\n - ` + + cyclicSchemaTools.join(`\n - `) + + `\n`; + error.message += extraDetails; + } + } + } + + /** + * @param transportContinuationPrefix - Text a previous attempt already + * delivered before a socket cut, which this attempt was asked to resume + * from (issue #7832). On success it is folded into the response parts + * before either durable write, so the JSONL transcript and in-memory + * history carry the same merged turn (issue #8094). Undefined on every + * non-continuation send. + */ + private async *processStreamResponse( + model: string, + streamResponse: AsyncGenerator, + routeKey: string, + goalContext?: GoalTurnPermit, + transportContinuationPrefix?: string, + acceptQuietToolResultCompletion = false, + ): AsyncGenerator { + // Collect ALL parts from the model response (including thoughts for recording) + const allModelParts: Part[] = []; + const usedToolCallIds = collectToolCallIdsFromHistory(this.history); + const rawToolCallIdsInCurrentTurn = new Set(); + const reservedToolCallIds = new Map(); + let usageMetadata: GenerateContentResponseUsageMetadata | undefined; + let coercedUsage: + | { + promptTokenCount: number; + totalTokenCount: number; + candidatesTokenCount: number; + cachedContentTokenCount: number; + thoughtsTokenCount: number; + } + | undefined; + + let hasToolCall = false; + let hasFinishReason = false; + const protocolTagDetector = new LeadingProtocolTagLeakDetector(); + let pendingProtocolParts: Part[] = []; + const takePendingProtocolParts = (): Part[] => { + const parts = pendingProtocolParts; + pendingProtocolParts = []; + const released: Part[] = []; + for (const part of parts) { + const previous = released.at(-1); + if ( + previous && + isValidNonThoughtTextPart(previous) && + isValidNonThoughtTextPart(part) + ) { + previous.text! += part.text!; + } else { + released.push(isValidNonThoughtTextPart(part) ? { ...part } : part); + } + } + return released; + }; + let protocolTextWasSuppressed = false; + const currentUserTurn = this.history[this.history.length - 1]; + const isToolResultContinuation = + currentUserTurn?.role === 'user' && + currentUserTurn.parts?.some((part) => part.functionResponse) === true; + let deferredFinishReason: FinishReason | undefined; + // Captured if the upstream stream throws mid-iteration (typical on weak + // networks: SSE drops between `content_block_stop` of a tool_use and the + // terminal `message_stop`). We still build / record / push a partial + // assistant turn below before re-throwing — see the dedicated branch in + // the post-loop block for why this is needed to keep tool_use/tool_result + // pairing intact across the failure. + let streamError: unknown = null; + + try { + for await (const chunk of streamResponse) { + const preparations = getToolCallPreparations(chunk); + if (preparations.length > 0) { + setToolCallPreparations( + chunk, + preparations.map((preparation) => ({ + ...preparation, + callId: reserveModelToolCallId( + preparation.callId, + usedToolCallIds, + reservedToolCallIds, + ), + })), + ); + } + + // Use ||= to avoid later usage-only chunks (no candidates) overwriting + // a finishReason that was already seen in an earlier chunk. + hasFinishReason ||= + chunk?.candidates?.some((candidate) => candidate.finishReason) ?? + false; + + if (isValidResponse(chunk)) { + const candidate = chunk.candidates?.[0]; + let content = candidate?.content; + if (candidate?.finishReason && !content?.parts) { + protocolTagDetector.finish(); + if (protocolTagDetector.leaked) { + pendingProtocolParts = []; + } else { + const parts = takePendingProtocolParts(); + if (parts.length > 0) { + content = { + ...content, + role: content?.role ?? 'model', + parts, + }; + candidate.content = content; + } + } + } + if (content?.parts) { + const outputParts: Part[] = []; + for (const part of content.parts) { + if ( + isToolResultContinuation && + !part.thought && + part.text?.trim() === GEMINI_EMPTY_CONTENT_PLACEHOLDER + ) { + continue; + } + if (typeof part.text !== 'string' || part.thought) { + if ( + pendingProtocolParts.length > 0 || + protocolTagDetector.leaked + ) { + pendingProtocolParts.push(part); + } else { + outputParts.push(part); + } + continue; + } + const text = protocolTagDetector.accept(part.text); + if (text) { + if (pendingProtocolParts.length > 0) { + outputParts.push(...takePendingProtocolParts(), part); + } else { + outputParts.push({ ...part, text }); + } + continue; + } + pendingProtocolParts.push(...outputParts.splice(0), part); + protocolTextWasSuppressed ||= part.text.length > 0; + } + content.parts = outputParts; + if (candidate?.finishReason) { + protocolTagDetector.finish(); + if (protocolTagDetector.leaked) { + pendingProtocolParts = []; + } else { + content.parts.push(...takePendingProtocolParts()); + } + } + content.parts = normalizeModelToolCallIds( + content.parts, + usedToolCallIds, + rawToolCallIdsInCurrentTurn, + reservedToolCallIds, + ); + syncFunctionCallsField(chunk, content.parts); + + if (content.parts.some((part) => part.functionCall)) { + hasToolCall = true; + } + + // Collect all parts for recording + allModelParts.push(...content.parts); + } + } + + // Collect token usage for consolidated recording + if (chunk.usageMetadata) { + usageMetadata = chunk.usageMetadata; + // Context usage tracks prompt size; output isn't in history yet. + // Coerce hostile-provider values (NaN / Infinity / negative) to 0 + // so the compaction gate arithmetic stays well-defined; see + // `coerceUsageCount` for the failure modes this guards against. + const hasUsablePromptTokenCount = + typeof usageMetadata.promptTokenCount === 'number' && + Number.isFinite(usageMetadata.promptTokenCount) && + usageMetadata.promptTokenCount >= 0; + const hasUsableTotalTokenCount = + typeof usageMetadata.totalTokenCount === 'number' && + Number.isFinite(usageMetadata.totalTokenCount) && + usageMetadata.totalTokenCount >= 0; + const promptTokenCount = coerceUsageCount( + usageMetadata.promptTokenCount, + 'promptTokenCount', + ); + const totalTokenCount = coerceUsageCount( + usageMetadata.totalTokenCount, + 'totalTokenCount', + ); + const candidatesTokenCount = coerceUsageCount( + usageMetadata.candidatesTokenCount, + 'candidatesTokenCount', + ); + const cachedContentTokenCount = coerceUsageCount( + usageMetadata.cachedContentTokenCount, + 'cachedContentTokenCount', + ); + const thoughtsTokenCount = coerceUsageCount( + usageMetadata.thoughtsTokenCount, + 'thoughtsTokenCount', + ); + // Stash coerced values so recordAssistantTurn can reuse them + // without re-calling coerceUsageCount inline. + coercedUsage = { + promptTokenCount, + totalTokenCount, + candidatesTokenCount, + cachedContentTokenCount, + thoughtsTokenCount, + }; + const lastPromptTokenCount = hasUsablePromptTokenCount + ? promptTokenCount + : totalTokenCount; + if (lastPromptTokenCount) { + // Always update the per-chat counter so this chat (including + // subagents) can make its own compaction decisions. + // Retain whatever route's counts currently occupy the slots + // before overwriting them: a foreign-keyed slot holds another + // route's state that its next keyed read still needs — mid-send + // compression can leave the slots keyed to the active route + // even though this report comes from the request route (#9506). + if ( + this.tokenCountsRouteKey !== undefined && + this.tokenCountsRouteKey !== routeKey + ) { + this.retainCurrentTokenCounts(); + } + this.lastPromptTokenCount = lastPromptTokenCount; + this.lastPromptTokenCountIsEstimated = false; + this.lastOutputTokenCount = hasUsablePromptTokenCount + ? getUsageOutputTokenCountForPromptEstimate({ + promptTokenCount, + ...(hasUsableTotalTokenCount ? { totalTokenCount } : {}), + candidatesTokenCount, + thoughtsTokenCount, + }) + : 0; + // Attribute these counts to the route that reported them so a + // later model switch invalidates them (#9454). + this.tokenCountsRouteKey = routeKey; + // A fresh API report supersedes anything retained for this + // route while another route owned the slots (#9506). + this.tokenCountsByRouteKey.delete(routeKey); + // Mirror to the global telemetry only when wired — subagents + // pass `telemetryService=undefined` to keep their context usage + // out of the main session's UI counters. + this.telemetryService?.setLastPromptTokenCount( + lastPromptTokenCount, + ); + if (cachedContentTokenCount && this.telemetryService) { + this.telemetryService.setLastCachedContentTokenCount( + cachedContentTokenCount, + ); + } + } + } + + if (isToolResultContinuation) { + // Do not let consumers commit Finished before post-stream validation + // can reject a semantically empty continuation. + for (const candidate of chunk.candidates ?? []) { + if (candidate.finishReason) { + deferredFinishReason ??= candidate.finishReason; + delete candidate.finishReason; + } + } + } + + if ( + !chunk.candidates?.length || + preparations.length > 0 || + !protocolTextWasSuppressed || + !protocolTagDetector.blockingOutput + ) { + yield chunk; + } + } + } catch (e) { + streamError = e; + } + + if ( + streamError === null && + pendingProtocolParts.length > 0 && + (hasToolCall || + pendingProtocolParts.some((part) => part.functionCall !== undefined)) + ) { + protocolTagDetector.finish(); + if (protocolTagDetector.leaked) { + pendingProtocolParts = []; + } else { + const parts = normalizeModelToolCallIds( + takePendingProtocolParts(), + usedToolCallIds, + rawToolCallIdsInCurrentTurn, + reservedToolCallIds, + ); + const chunk = { + candidates: [{ content: { role: 'model', parts } }], + } as GenerateContentResponse; + syncFunctionCallsField(chunk, parts); + hasToolCall ||= parts.some((part) => part.functionCall); + allModelParts.push(...parts); + yield chunk; + } + } + + let thoughtContentPart: Part | undefined; + const thoughtText = allModelParts + .filter((part) => part.thought) + .map((part) => part.text) + .join('') + .trim(); + + if (thoughtText !== '') { + thoughtContentPart = { + text: thoughtText, + thought: true, + }; + + const thoughtSignature = allModelParts.filter( + (part) => part.thoughtSignature && part.thought, + )?.[0]?.thoughtSignature; + if (thoughtContentPart && thoughtSignature) { + thoughtContentPart.thoughtSignature = thoughtSignature; + } + } + + let contentParts = allModelParts.filter((part) => !part.thought); + const consolidatedHistoryParts: Part[] = []; + for (const part of contentParts) { + const lastPart = + consolidatedHistoryParts[consolidatedHistoryParts.length - 1]; + if ( + lastPart?.text && + isValidNonThoughtTextPart(lastPart) && + isValidNonThoughtTextPart(part) + ) { + lastPart.text += part.text; + } else if (isValidContentPart(part)) { + consolidatedHistoryParts.push(part); + } + } + + let contentText = consolidatedHistoryParts + .filter((part) => part.text) + .map((part) => part.text) + .join('') + .trim(); + + // Deferred until after the throw sites below so a protocol-tag leak + // or stream-validation failure cannot dispatch a recovered call that + // the retry path would then execute a second time. + let recoveredChunk: GenerateContentResponse | null = null; + + // XML tool call fallback: some models (e.g. qwen3.8-max-preview in very + // long contexts) occasionally emit tool calls as raw XML in the content + // field instead of using the structured tool_calls array. Detect and + // recover these so the agent loop is not broken. See #8003. + if ( + streamError === null && + !hasToolCall && + hasFinishReason && + contentText && + containsXmlToolCalls(contentText) + ) { + const recovery = tryRecoverXmlToolCalls(contentText); + if (recovery.recovered) { + hasToolCall = true; + // recovery.remainingText is derived from the join of ALL text + // parts, so every text part is consumed. Remove them, reinsert + // remainingText at the first text position so non-text parts + // (inlineData/fileData) keep their original relative order, and + // append functionCallParts at the end. + const textIndices: number[] = []; + for (let i = 0; i < consolidatedHistoryParts.length; i++) { + if (consolidatedHistoryParts[i]!.text !== undefined) + textIndices.push(i); + } + for (let j = textIndices.length - 1; j >= 0; j--) { + consolidatedHistoryParts.splice(textIndices[j]!, 1); + } + const insertAt = Math.min( + textIndices[0] ?? 0, + consolidatedHistoryParts.length, + ); + if (recovery.remainingText) { + consolidatedHistoryParts.splice(insertAt, 0, { + text: recovery.remainingText, + }); + } + consolidatedHistoryParts.push(...recovery.functionCallParts); + // Recompute contentText and contentParts so the JSONL recording + // below stays aligned with in-memory history (--resume fidelity). + contentText = consolidatedHistoryParts + .filter((part) => part.text) + .map((part) => part.text) + .join('') + .trim(); + contentParts = consolidatedHistoryParts; + // Build a synthetic chunk so the agent loop (turn.ts) actually + // executes the recovered tool calls; yielded after the throw sites. + const syntheticChunk = { + candidates: [ + { + content: { role: 'model', parts: recovery.functionCallParts }, + }, + ], + } as GenerateContentResponse; + syncFunctionCallsField(syntheticChunk, recovery.functionCallParts); + recoveredChunk = syntheticChunk; + debugLogger.warn( + `XML tool call fallback: recovered ${recovery.functionCallParts.length} tool call(s) [${recovery.functionCallParts.map((p) => p.functionCall?.name).join(', ')}] from plain text content (contentLength=${contentText.length})`, + ); + } else { + debugLogger.warn( + `XML tool call fallback: detected XML tool calls but recovery was rejected (prose ratio too high or no parameterized blocks), contentLength=${contentText.length}`, + ); + } + } + + if (streamError === null && protocolTagDetector.leaked && !hasToolCall) { + throw new InvalidStreamError( + 'Model response started with leaked protocol tags.', + 'PROTOCOL_TAG_LEAK', + ); + } + + // Stream validation logic: A stream is considered successful if: + // 1. There's a tool call (tool calls can end without explicit finish reasons), OR + // 2. There's a finish reason AND we have non-empty response text or thought text + // + // Thought-only responses remain valid for ordinary user turns. After a + // tool result, they do not advance the agent without text or another + // tool call, so they retry (#7039) — and once that retry budget is + // exhausted the quiet completion is accepted rather than failing the + // run (#9026): some model families legitimately end turns silently + // after a tool result. + const hasAnyContent = contentText || thoughtText; + const lacksVisibleToolResultProgress = + isToolResultContinuation && + (!contentText || contentText === GEMINI_EMPTY_CONTENT_PLACEHOLDER); + let acceptedQuietToolResultCompletion = false; + if ( + streamError === null && + !hasToolCall && + (!hasFinishReason || !hasAnyContent || lacksVisibleToolResultProgress) + ) { + if (!hasFinishReason) { + throw new InvalidStreamError( + 'Model stream ended without a finish reason.', + 'NO_FINISH_REASON', + ); + } + if (lacksVisibleToolResultProgress) { + const truncatedAtMaxTokens = + deferredFinishReason === FinishReason.MAX_TOKENS; + // Only STOP is a complete, non-truncated, non-blocked quiet turn end. + // Unknown converter fall-through values such as + // FINISH_REASON_UNSPECIFIED must fail closed instead of being accepted + // as an empty model turn. + const unsupportedQuietFinishReason = + deferredFinishReason !== FinishReason.STOP; + if ( + truncatedAtMaxTokens || + unsupportedQuietFinishReason || + !acceptQuietToolResultCompletion + ) { + throw new InvalidStreamError( + 'Model stream ended after a tool result without visible progress.', + truncatedAtMaxTokens + ? 'NO_TOOL_RESULT_PROGRESS_MAX_TOKENS' + : 'NO_TOOL_RESULT_PROGRESS', + ); + } + // Retry budget exhausted and the model still ends the turn quietly + // with a valid finish reason (#9026). Accept it as completion. + // When the attempt produced nothing at all, the canonical + // placeholder is appended to `acceptedTurnParts` below — the + // single source for both the JSONL record and the history push, + // keeping user/model alternation well-formed for the next request + // while transcript and history agree. + acceptedQuietToolResultCompletion = true; + debugLogger.warn( + 'Accepting quiet post-tool-result completion after retry budget ' + + 'exhaustion (#9026)', + ); + } else { + throw new InvalidStreamError( + 'Model stream ended with empty response text.', + 'NO_RESPONSE_TEXT', + ); + } + } + + if (recoveredChunk) { + yield recoveredChunk; + } + + // Record assistant turn with raw Content and metadata. Gate matches + // the in-memory `this.history.push` decision below so chat-recording + // JSONL never carries a partial turn we deliberately dropped from + // history: on `--resume` the transcript-load path would otherwise + // re-inject a model turn the in-session run intentionally discarded + // (text-only mid-stream errors, where the Retry re-issues the user + // prompt — a stale partial-text record would bias the resumed + // conversation or surface as duplicate output). + const willPersistToHistory = + streamError === null || + (hasToolCall && + (thoughtContentPart || consolidatedHistoryParts.length > 0)); + // Transport-continuation merge (issue #8094). `allModelParts` is + // per-attempt, so a continuation's parts carry the resumed remainder only. + // Fold the already-delivered prefix back in HERE — into the parts + // themselves, before either durable write — so the JSONL record below and + // the `this.history.push` further down are derived from the same data and + // cannot disagree. Otherwise `--resume` rehydrates a turn that starts + // mid-sentence while the live session shows a coherent answer. + // + // Merging in one place is load-bearing, not tidiness: + // - Computing the record's text and history's text from separate + // expressions lets them drift. They already would: `contentText` is + // trimmed (see its definition above) while the pushed parts are raw, + // so deduping the record against the trimmed text fuses words when the + // remainder opens with whitespace ("The result is" + " 42." → + // "The result is42."). + // - Writing them at different times opens a window. The record is + // appended below, the history push happens after it, and a + // `deferredFinishReason` chunk is yielded after that — a suspension + // point. A consumer abandoning iteration there (an abort inside + // `Turn.run`) would strand a merged record against a remainder-only + // history, and the JSONL is append-only so nothing reconciles it. + // + // Placed after the stream-validation throws above so an empty continuation + // still fails validation on its own merits rather than being masked by the + // prefix. + // + // Success only. On `streamError !== null` the parts must keep matching the + // remainder-only partial that survives in history (the + // `pendingPartialAssistantRecord` path below) — the prefix belongs to an + // attempt that did not survive, and a fresh-restart retry discards it via + // `resetTransportContinuation`. + if (streamError === null && transportContinuationPrefix) { + const textIndex = consolidatedHistoryParts.findIndex(isPlainTextPart); + if (textIndex < 0) { + // Continuation returned no text of its own (e.g. only a functionCall). + // `thoughtContentPart` is prepended separately at the push below, so + // index 0 here is already "after any leading thought part". + consolidatedHistoryParts.unshift({ text: transportContinuationPrefix }); + } else { + const remainderPart = consolidatedHistoryParts[textIndex] as Part & { + text: string; + }; + consolidatedHistoryParts[textIndex] = { + ...remainderPart, + text: mergeDeliveredPrefix( + transportContinuationPrefix, + remainderPart.text, + ), + }; + } + contentText = consolidatedHistoryParts + .filter((part) => part.text) + .map((part) => part.text) + .join('') + .trim(); + } + // The exact parts the accepted turn will carry into `this.history.push` + // below — computed once, before the JSONL record, so an accepted quiet + // completion records exactly what history keeps (including non-text + // parts like inlineData, which have no slot in the text/toolCall + // assembly and would otherwise desync transcript from history on + // `--resume`). + const acceptedTurnParts: Part[] = [ + ...(thoughtContentPart ? [thoughtContentPart] : []), + ...consolidatedHistoryParts, + ]; + if (acceptedQuietToolResultCompletion && acceptedTurnParts.length === 0) { + acceptedTurnParts.push({ text: GEMINI_EMPTY_CONTENT_PLACEHOLDER }); + } + if ( + willPersistToHistory && + (acceptedQuietToolResultCompletion || + thoughtContentPart || + contentText || + hasToolCall || + usageMetadata) + ) { + const contextWindowSize = + this.config.getContentGeneratorConfig()?.contextWindowSize; + const recordArgs = { + model, + message: acceptedQuietToolResultCompletion + ? acceptedTurnParts + : [ + ...(thoughtContentPart ? [thoughtContentPart] : []), + ...(contentText ? [{ text: contentText }] : []), + ...(hasToolCall + ? contentParts + .map(redactStructuredOutputArgsForRecording) + .filter( + ( + p, + ): p is { + functionCall: NonNullable; + } => p !== null, + ) + : []), + ], + tokens: coercedUsage + ? { ...usageMetadata, ...coercedUsage } + : usageMetadata, + contextWindowSize, + ...(goalContext ? { goalContext: { ...goalContext } } : {}), + }; + if (streamError !== null) { + // Stream-error + tool-use partial: defer the JSONL append until + // the outer retry loop decides whether to roll back this attempt. + // If the same send retries successfully, popPartialIfPushed clears + // this stash and the failed attempt never lands on disk; if the + // retry path doesn't apply (unretryable break), the stash is + // flushed at the rethrow site so JSONL stays aligned with the + // partial that survives in-memory. Without this, retry-success + // leaves a failed `model[functionCall]` durable in JSONL and + // `--resume` rehydrates a turn the live session correctly + // discarded. + this.pendingPartialAssistantRecord = recordArgs; + } else { + this.chatRecordingService?.recordAssistantTurn(recordArgs); + } + } + + // Mid-stream failure recovery (Race C in the canonical note above + // `ORPHAN_TOOL_USE_REPAIR_REASON`): if the upstream stream threw + // AFTER a `functionCall` chunk was already yielded — typical on + // weak networks: SSE cut between a tool_use `content_block_stop` + // and the terminal `message_stop` — we persist the partial + // assistant turn so the React scheduler's incoming + // `user[functionResponse]` has a matching `model[tool_use]` to + // pair with. + // + // Plain-text partial turns (no functionCall yielded) are + // deliberately NOT persisted — the Retry path pops the trailing + // user prompt and re-issues it; a stale partial-text model turn + // between them would either bias the retry or surface as a + // duplicate. + if (streamError !== null) { + // Reuse the `willPersistToHistory` gate from the recordAssistantTurn + // block above instead of re-deriving it. When `streamError !== null`, + // `willPersistToHistory` reduces to exactly the original expression + // `hasToolCall && (thoughtContentPart || consolidatedHistoryParts.length > 0)`; + // sharing the single binding eliminates drift risk if one gate is + // tightened without the other and the JSONL recording silently + // desyncs from in-memory history. + if (willPersistToHistory) { + this.history.push({ + role: 'model', + parts: [ + ...(thoughtContentPart ? [thoughtContentPart] : []), + ...consolidatedHistoryParts, + ], + }); + // Track the pushed turn so the outer sendMessageStream retry loop + // can roll it back if it decides to retry the same send. Without + // this, a successful retry would leave the failed attempt's + // partial `model[functionCall]` as a stale leading model turn in + // front of the retry's real response. + this.pendingPartialAssistantTurnIndex = this.history.length - 1; + // Trace the push event so the lifecycle is observable end-to-end: + // dedup in `useLlmStream.handleCompletedTools` already logs + // `[REPAIR] Dropping ...`, and `repairOrphanedToolUseTurnsInHistory` + // logs `[REPAIR] Synthesized ...`. Without a corresponding + // `[PARTIAL_PUSH]` line here, an investigator looking at a + // stale-partial wedge sees the downstream symptom but has no + // anchor for when/why the partial originated. + debugLogger.warn( + '[PARTIAL_PUSH] Persisting partial assistant turn for ' + + 'mid-stream error recovery (will be rolled back if retry ' + + 'succeeds, kept if break is unretryable). ' + + `pendingIndex=${this.pendingPartialAssistantTurnIndex} ` + + `callIds=${consolidatedHistoryParts + .map((p) => p.functionCall?.id) + .filter((id): id is string => Boolean(id)) + .join(',')} ` + + `error=${ + streamError instanceof Error + ? streamError.message + : String(streamError) + }`, + ); + } + throw streamError; + } + + this.history.push({ + role: 'model', + parts: acceptedTurnParts, + }); + if (deferredFinishReason) { + yield { + candidates: [{ finishReason: deferredFinishReason }], + usageMetadata, + } as GenerateContentResponse; + } + } + + /** + * Merge `pairCount` trailing (user_recovery, model_continuation) pairs back + * into the model turn that precedes them. Used after the output-token + * recovery loop so the internal OUTPUT_RECOVERY_MESSAGE control prompt + * does not persist in durable history as if the user sent it. + * + * Expected tail shape per iteration (walking from the back): + * [..., precedingModel, userRecovery, modelContinuation] + * + * If any pair doesn't match that shape the method bails defensively + * rather than corrupting history. + */ + private coalesceRecoveryPairs(pairCount: number): void { + for (let i = 0; i < pairCount; i++) { + const len = this.history.length; + if (len < 3) return; + + const modelContinuation = this.history[len - 1]!; + const userRecovery = this.history[len - 2]!; + const precedingModel = this.history[len - 3]!; + + if ( + modelContinuation.role !== 'model' || + userRecovery.role !== 'user' || + precedingModel.role !== 'model' + ) { + return; + } + + precedingModel.parts = appendRecoveryContinuationParts( + precedingModel.parts, + modelContinuation.parts, + ); + // Drop the (userRecovery, modelContinuation) pair. + this.history.splice(len - 2, 2); + } + } +} + +/** Visible for Testing */ +export function isSchemaDepthError(errorMessage: string): boolean { + return errorMessage.includes('maximum schema depth exceeded'); +} + +export function isInvalidArgumentError(errorMessage: string): boolean { + return errorMessage.includes('Request contains an invalid argument'); +} + +/** @deprecated Use `LlmChat`; retained until a future major release. */ +export { LlmChat as GeminiChat }; diff --git a/packages/core/src/core/geminiContentGenerator/index.test.ts b/packages/core/src/core/llm-content-generator/index.test.ts similarity index 71% rename from packages/core/src/core/geminiContentGenerator/index.test.ts rename to packages/core/src/core/llm-content-generator/index.test.ts index cebbba390b3..3733eb8c977 100644 --- a/packages/core/src/core/geminiContentGenerator/index.test.ts +++ b/packages/core/src/core/llm-content-generator/index.test.ts @@ -5,16 +5,16 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createGeminiContentGenerator } from './index.js'; -import { GeminiContentGenerator } from './geminiContentGenerator.js'; +import { createLlmContentGenerator } from './index.js'; +import { LlmContentGenerator } from './llm-content-generator.js'; import type { Config } from '../../config/config.js'; import { AuthType } from '../contentGenerator.js'; -vi.mock('./geminiContentGenerator.js', () => ({ - GeminiContentGenerator: vi.fn().mockImplementation(() => ({})), +vi.mock('./llm-content-generator.js', () => ({ + LlmContentGenerator: vi.fn().mockImplementation(() => ({})), })); -describe('createGeminiContentGenerator', () => { +describe('createLlmContentGenerator', () => { let mockConfig: Config; beforeEach(() => { @@ -28,16 +28,16 @@ describe('createGeminiContentGenerator', () => { } as unknown as Config; }); - it('should create a GeminiContentGenerator', () => { + it('should create a LlmContentGenerator', () => { const config = { model: 'gemini-1.5-flash', apiKey: 'test-key', authType: AuthType.USE_GEMINI, }; - const generator = createGeminiContentGenerator(config, mockConfig); + const generator = createLlmContentGenerator(config, mockConfig); - expect(GeminiContentGenerator).toHaveBeenCalled(); + expect(LlmContentGenerator).toHaveBeenCalled(); expect(generator).toBeDefined(); }); @@ -49,9 +49,9 @@ describe('createGeminiContentGenerator', () => { baseUrl: 'https://proxy.example.com/gemini', }; - createGeminiContentGenerator(config, mockConfig); + createLlmContentGenerator(config, mockConfig); - expect(GeminiContentGenerator).toHaveBeenCalledWith( + expect(LlmContentGenerator).toHaveBeenCalledWith( expect.objectContaining({ httpOptions: expect.objectContaining({ headers: expect.objectContaining({ @@ -71,9 +71,9 @@ describe('createGeminiContentGenerator', () => { authType: AuthType.USE_GEMINI, }; - createGeminiContentGenerator(config, mockConfig); + createLlmContentGenerator(config, mockConfig); - expect(GeminiContentGenerator).toHaveBeenCalledWith( + expect(LlmContentGenerator).toHaveBeenCalledWith( expect.objectContaining({ httpOptions: expect.objectContaining({ headers: expect.objectContaining({ @@ -83,7 +83,7 @@ describe('createGeminiContentGenerator', () => { }), config, ); - expect(vi.mocked(GeminiContentGenerator).mock.calls[0]?.[0]).not.toEqual( + expect(vi.mocked(LlmContentGenerator).mock.calls[0]?.[0]).not.toEqual( expect.objectContaining({ httpOptions: expect.objectContaining({ baseUrl: expect.any(String), diff --git a/packages/core/src/core/geminiContentGenerator/index.ts b/packages/core/src/core/llm-content-generator/index.ts similarity index 85% rename from packages/core/src/core/geminiContentGenerator/index.ts rename to packages/core/src/core/llm-content-generator/index.ts index 88bb68da432..229217f47c4 100644 --- a/packages/core/src/core/geminiContentGenerator/index.ts +++ b/packages/core/src/core/llm-content-generator/index.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { GeminiContentGenerator } from './geminiContentGenerator.js'; +import { LlmContentGenerator } from './llm-content-generator.js'; import { AuthType } from '../contentGenerator.js'; import type { ContentGenerator, @@ -13,12 +13,12 @@ import type { import type { Config } from '../../config/config.js'; import { InstallationManager } from '../../config/installationManager.js'; -export { GeminiContentGenerator } from './geminiContentGenerator.js'; +export { LlmContentGenerator } from './llm-content-generator.js'; /** - * Create a Gemini content generator. + * Create the Google GenAI-backed LLM content generator. */ -export function createGeminiContentGenerator( +export function createLlmContentGenerator( config: ContentGeneratorConfig, gcConfig: Config, ): ContentGenerator { @@ -46,7 +46,7 @@ export function createGeminiContentGenerator( } : { headers }; - const geminiContentGenerator = new GeminiContentGenerator( + const llmContentGenerator = new LlmContentGenerator( { apiKey: config.apiKey === '' ? undefined : config.apiKey, // Derive Vertex mode from the auth type rather than leaving it to the @@ -63,5 +63,5 @@ export function createGeminiContentGenerator( config, ); - return geminiContentGenerator; + return llmContentGenerator; } diff --git a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts similarity index 90% rename from packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts rename to packages/core/src/core/llm-content-generator/llm-content-generator.test.ts index ffa7653c403..50d0ca09860 100644 --- a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts @@ -5,12 +5,12 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { GeminiContentGenerator } from './geminiContentGenerator.js'; +import { LlmContentGenerator } from './llm-content-generator.js'; import { GoogleGenAI } from '@google/genai'; -const mockReportGeminiRequest = vi.hoisted(() => vi.fn()); -const mockReportGeminiResponse = vi.hoisted(() => vi.fn()); -const mockReportGeminiChunk = vi.hoisted(() => vi.fn()); +const mockReportLlmRequest = vi.hoisted(() => vi.fn()); +const mockReportLlmResponse = vi.hoisted(() => vi.fn()); +const mockReportLlmChunk = vi.hoisted(() => vi.fn()); vi.mock('@google/genai', () => { const mockGenerateContent = vi.fn(); @@ -28,19 +28,19 @@ vi.mock('@google/genai', () => { }; }); vi.mock('../../telemetry/gen-ai-request.js', () => ({ - reportGeminiRequest: mockReportGeminiRequest, - reportGeminiResponse: mockReportGeminiResponse, - reportGeminiChunk: mockReportGeminiChunk, + reportLlmRequest: mockReportLlmRequest, + reportLlmResponse: mockReportLlmResponse, + reportLlmChunk: mockReportLlmChunk, })); -describe('GeminiContentGenerator', () => { - let generator: GeminiContentGenerator; +describe('LlmContentGenerator', () => { + let generator: LlmContentGenerator; // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockGoogleGenAI: any; beforeEach(() => { vi.clearAllMocks(); - generator = new GeminiContentGenerator({ + generator = new LlmContentGenerator({ apiKey: 'test-api-key', }); mockGoogleGenAI = vi.mocked(GoogleGenAI).mock.results[0].value; @@ -49,7 +49,7 @@ describe('GeminiContentGenerator', () => { it('should merge customHeaders into existing httpOptions.headers', async () => { vi.mocked(GoogleGenAI).mockClear(); - void new GeminiContentGenerator( + void new LlmContentGenerator( { apiKey: 'test-api-key', httpOptions: { @@ -86,7 +86,7 @@ describe('GeminiContentGenerator', () => { const expectedResponse = { responseId: 'test-id' }; mockGoogleGenAI.models.generateContent.mockResolvedValue(expectedResponse); const telemetryAttempt = {}; - mockReportGeminiRequest.mockReturnValueOnce(telemetryAttempt); + mockReportLlmRequest.mockReturnValueOnce(telemetryAttempt); const response = await generator.generateContent(request, 'prompt-id'); @@ -103,10 +103,10 @@ describe('GeminiContentGenerator', () => { }), }), ); - expect(mockReportGeminiRequest).toHaveBeenCalledWith( + expect(mockReportLlmRequest).toHaveBeenCalledWith( mockGoogleGenAI.models.generateContent.mock.calls[0][0], ); - expect(mockReportGeminiResponse).toHaveBeenCalledWith( + expect(mockReportLlmResponse).toHaveBeenCalledWith( telemetryAttempt, expectedResponse, ); @@ -146,7 +146,7 @@ describe('GeminiContentGenerator', () => { })(); mockGoogleGenAI.models.generateContentStream.mockResolvedValue(mockStream); const telemetryAttempt = {}; - mockReportGeminiRequest.mockReturnValueOnce(telemetryAttempt); + mockReportLlmRequest.mockReturnValueOnce(telemetryAttempt); const stream = await generator.generateContentStream(request, 'prompt-id'); @@ -163,14 +163,14 @@ describe('GeminiContentGenerator', () => { }), }), ); - expect(mockReportGeminiRequest).toHaveBeenCalledWith( + expect(mockReportLlmRequest).toHaveBeenCalledWith( mockGoogleGenAI.models.generateContentStream.mock.calls[0][0], ); expect(await stream.next()).toEqual({ done: false, value: { responseId: '1' }, }); - expect(mockReportGeminiChunk).toHaveBeenCalledWith(telemetryAttempt, { + expect(mockReportLlmChunk).toHaveBeenCalledWith(telemetryAttempt, { responseId: '1', }); }); @@ -206,7 +206,7 @@ describe('GeminiContentGenerator', () => { ); await expect(stream.next()).rejects.toBe(failure); - expect(mockReportGeminiChunk).not.toHaveBeenCalled(); + expect(mockReportLlmChunk).not.toHaveBeenCalled(); }); it('should call embedContent on the underlying model', async () => { @@ -221,7 +221,7 @@ describe('GeminiContentGenerator', () => { }); it('should prioritize contentGeneratorConfig samplingParams over request config', async () => { - const generatorWithParams = new GeminiContentGenerator({ apiKey: 'test' }, { + const generatorWithParams = new LlmContentGenerator({ apiKey: 'test' }, { model: 'gemini-1.5-flash', samplingParams: { temperature: 0.1, @@ -252,16 +252,13 @@ describe('GeminiContentGenerator', () => { }); it('should map reasoning effort to thinkingConfig', async () => { - const generatorWithReasoning = new GeminiContentGenerator( - { apiKey: 'test' }, - { - model: 'gemini-2.5-pro', - reasoning: { - effort: 'high', - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any, - ); + const generatorWithReasoning = new LlmContentGenerator({ apiKey: 'test' }, { + model: 'gemini-2.5-pro', + reasoning: { + effort: 'high', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); const request = { model: 'gemini-2.5-pro', @@ -285,7 +282,7 @@ describe('GeminiContentGenerator', () => { it("maps reasoning effort 'max' to HIGH (Gemini has no higher tier)", async () => { // 'max' is a DeepSeek-specific extension. Gemini caps at HIGH, so the // converter must clamp instead of falling through to UNSPECIFIED. - const generatorWithMax = new GeminiContentGenerator({ apiKey: 'test' }, { + const generatorWithMax = new LlmContentGenerator({ apiKey: 'test' }, { model: 'gemini-2.5-pro', reasoning: { effort: 'max' }, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -309,7 +306,7 @@ describe('GeminiContentGenerator', () => { }); it("maps reasoning effort 'medium' to MEDIUM", async () => { - const generatorWithMedium = new GeminiContentGenerator({ apiKey: 'test' }, { + const generatorWithMedium = new LlmContentGenerator({ apiKey: 'test' }, { model: 'gemini-2.5-pro', reasoning: { effort: 'medium' }, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -333,7 +330,7 @@ describe('GeminiContentGenerator', () => { }); it("clamps reasoning effort 'xhigh' to HIGH (Gemini has no xhigh tier)", async () => { - const generatorWithXhigh = new GeminiContentGenerator({ apiKey: 'test' }, { + const generatorWithXhigh = new LlmContentGenerator({ apiKey: 'test' }, { model: 'gemini-2.5-pro', reasoning: { effort: 'xhigh' }, // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.ts similarity index 95% rename from packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts rename to packages/core/src/core/llm-content-generator/llm-content-generator.ts index d9e8e3241cd..c23f72cc355 100644 --- a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.ts @@ -21,15 +21,15 @@ import type { } from '../contentGenerator.js'; import { createDebugLogger } from '../../utils/debugLogger.js'; import { - reportGeminiChunk, - reportGeminiRequest, - reportGeminiResponse, + reportLlmChunk, + reportLlmRequest, + reportLlmResponse, type GenAiAttemptHandle, } from '../../telemetry/gen-ai-request.js'; const debugLogger = createDebugLogger('GEMINI'); -function observeGeminiStream( +function observeLlmStream( stream: AsyncIterable, telemetryAttempt: GenAiAttemptHandle | undefined, ): AsyncGenerator { @@ -37,7 +37,7 @@ function observeGeminiStream( return { async next() { const result = await iterator.next(); - if (!result.done) reportGeminiChunk(telemetryAttempt, result.value); + if (!result.done) reportLlmChunk(telemetryAttempt, result.value); return result; }, async return(value?: GenerateContentResponse) { @@ -58,7 +58,7 @@ function observeGeminiStream( /** * A wrapper for GoogleGenAI that implements the ContentGenerator interface. */ -export class GeminiContentGenerator implements ContentGenerator { +export class LlmContentGenerator implements ContentGenerator { private readonly googleGenAI: GoogleGenAI; private readonly contentGeneratorConfig?: ContentGeneratorConfig; // Latch so the effort-clamp warning fires once per generator lifetime @@ -223,10 +223,10 @@ export class GeminiContentGenerator implements ContentGenerator { contents: this.stripUnsupportedFields(request.contents), config: this.buildGenerateContentConfig(request), }; - const telemetryAttempt = reportGeminiRequest(finalRequest); + const telemetryAttempt = reportLlmRequest(finalRequest); const response = await this.googleGenAI.models.generateContent(finalRequest); - reportGeminiResponse(telemetryAttempt, response); + reportLlmResponse(telemetryAttempt, response); return response; } @@ -239,10 +239,10 @@ export class GeminiContentGenerator implements ContentGenerator { contents: this.stripUnsupportedFields(request.contents), config: this.buildGenerateContentConfig(request), }; - const telemetryAttempt = reportGeminiRequest(finalRequest); + const telemetryAttempt = reportLlmRequest(finalRequest); const stream = await this.googleGenAI.models.generateContentStream(finalRequest); - return observeGeminiStream(stream, telemetryAttempt); + return observeLlmStream(stream, telemetryAttempt); } /** diff --git a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts index 7a0867e46ce..6e6b2feb00d 100644 --- a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts +++ b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts @@ -293,16 +293,16 @@ function createOwnedLlmSpan( }; } -const realConvertGeminiRequestToOpenAI = - OpenAIContentConverter.convertGeminiRequestToOpenAI; -const convertGeminiRequestToOpenAISpy = vi - .spyOn(OpenAIContentConverter, 'convertGeminiRequestToOpenAI') +const realConvertLlmRequestToOpenAI = + OpenAIContentConverter.convertLlmRequestToOpenAI; +const convertLlmRequestToOpenAISpy = vi + .spyOn(OpenAIContentConverter, 'convertLlmRequestToOpenAI') .mockReturnValue([{ role: 'user', content: 'converted' }]); -const convertGeminiToolsToOpenAISpy = vi - .spyOn(OpenAIContentConverter, 'convertGeminiToolsToOpenAI') +const convertLlmToolsToOpenAISpy = vi + .spyOn(OpenAIContentConverter, 'convertLlmToolsToOpenAI') .mockResolvedValue([{ type: 'function', function: { name: 'tool' } }]); -const convertGeminiResponseToOpenAISpy = vi - .spyOn(OpenAIContentConverter, 'convertGeminiResponseToOpenAI') +const convertLlmResponseToOpenAISpy = vi + .spyOn(OpenAIContentConverter, 'convertLlmResponseToOpenAI') .mockReturnValue({ id: 'openai-response', object: 'chat.completion', @@ -418,9 +418,9 @@ describe('LoggingContentGenerator', () => { afterEach(() => { vi.useRealTimers(); - convertGeminiRequestToOpenAISpy.mockClear(); - convertGeminiToolsToOpenAISpy.mockClear(); - convertGeminiResponseToOpenAISpy.mockClear(); + convertLlmRequestToOpenAISpy.mockClear(); + convertLlmToolsToOpenAISpy.mockClear(); + convertLlmResponseToOpenAISpy.mockClear(); }); it('passes the owning config identity to a standalone LLM span', async () => { @@ -662,9 +662,9 @@ describe('LoggingContentGenerator', () => { expect(responseEvent.input_token_count).toBe(3); expect(responseEvent.response_text).toBe('ok'); - expect(convertGeminiRequestToOpenAISpy).toHaveBeenCalledTimes(1); - expect(convertGeminiToolsToOpenAISpy).toHaveBeenCalledTimes(1); - expect(convertGeminiResponseToOpenAISpy).toHaveBeenCalledTimes(1); + expect(convertLlmRequestToOpenAISpy).toHaveBeenCalledTimes(1); + expect(convertLlmToolsToOpenAISpy).toHaveBeenCalledTimes(1); + expect(convertLlmResponseToOpenAISpy).toHaveBeenCalledTimes(1); const openaiLoggerInstance = vi.mocked(OpenAILogger).mock.results[0] ?.value as { logInteraction: ReturnType }; @@ -1895,9 +1895,8 @@ describe('LoggingContentGenerator', () => { expect(responseEvent.input_token_count).toBe(2); expect(responseEvent.response_text).toBe('Hello world'); - expect(convertGeminiResponseToOpenAISpy).toHaveBeenCalledTimes(1); - const [consolidatedResponse] = - convertGeminiResponseToOpenAISpy.mock.calls[0]; + expect(convertLlmResponseToOpenAISpy).toHaveBeenCalledTimes(1); + const [consolidatedResponse] = convertLlmResponseToOpenAISpy.mock.calls[0]; const consolidatedParts = consolidatedResponse.candidates?.[0]?.content?.parts || []; expect(consolidatedParts).toEqual([ @@ -1947,11 +1946,11 @@ describe('LoggingContentGenerator', () => { }); const consolidateSpy = vi.spyOn( generator as unknown as { - consolidateGeminiResponsesForLogging: ( + consolidateLlmResponsesForLogging: ( responses: GenerateContentResponse[], ) => GenerateContentResponse | undefined; }, - 'consolidateGeminiResponsesForLogging', + 'consolidateLlmResponsesForLogging', ); const stream = await generator.generateContentStream( @@ -2834,9 +2833,9 @@ describe('LoggingContentGenerator', () => { }); it('uses generator modalities when converting logged OpenAI requests', async () => { - convertGeminiRequestToOpenAISpy.mockImplementationOnce( + convertLlmRequestToOpenAISpy.mockImplementationOnce( (request, requestContext, options) => - realConvertGeminiRequestToOpenAI(request, requestContext, options), + realConvertLlmRequestToOpenAI(request, requestContext, options), ); const wrapped = createWrappedGenerator( @@ -2881,7 +2880,7 @@ describe('LoggingContentGenerator', () => { await generator.generateContent(request, 'prompt-5'); - expect(convertGeminiRequestToOpenAISpy).toHaveBeenCalledWith( + expect(convertLlmRequestToOpenAISpy).toHaveBeenCalledWith( request, expect.objectContaining({ model: 'test-model', @@ -2912,9 +2911,9 @@ describe('LoggingContentGenerator', () => { }); it('uses string tool result content in reconstructed OpenAI logs when configured', async () => { - convertGeminiRequestToOpenAISpy.mockImplementationOnce( + convertLlmRequestToOpenAISpy.mockImplementationOnce( (request, requestContext, options) => - realConvertGeminiRequestToOpenAI(request, requestContext, options), + realConvertLlmRequestToOpenAI(request, requestContext, options), ); const wrapped = createWrappedGenerator( @@ -3128,7 +3127,7 @@ describe('LoggingContentGenerator', () => { // No capture fires, so resolve() falls through to the synthetic builder. // Force the synthetic build to throw, then verify the API result still surfaces. - convertGeminiRequestToOpenAISpy.mockImplementationOnce(() => { + convertLlmRequestToOpenAISpy.mockImplementationOnce(() => { throw new Error('synth-fail-success'); }); @@ -3152,7 +3151,7 @@ describe('LoggingContentGenerator', () => { enableOpenAILogging: true, openAILoggingDir: 'logs', }); - convertGeminiRequestToOpenAISpy.mockImplementationOnce(() => { + convertLlmRequestToOpenAISpy.mockImplementationOnce(() => { throw new Error('synth-fail-error'); }); diff --git a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts index 41d7760b99a..54d252399a9 100644 --- a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts +++ b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts @@ -858,7 +858,7 @@ export class LoggingContentGenerator implements ContentGenerator { responses.push(lastResponseForLogging); } const consolidatedResponse = shouldCollectResponses - ? this.consolidateGeminiResponsesForLogging(responses) + ? this.consolidateLlmResponsesForLogging(responses) : undefined; if (consolidatedResponse) { consolidatedResponse.usageMetadata = lastUsageMetadata; @@ -979,7 +979,7 @@ export class LoggingContentGenerator implements ContentGenerator { } const requestContext = this.createLoggingRequestContext(request.model); - const messages = OpenAIContentConverter.convertGeminiRequestToOpenAI( + const messages = OpenAIContentConverter.convertLlmRequestToOpenAI( request, requestContext, { @@ -994,7 +994,7 @@ export class LoggingContentGenerator implements ContentGenerator { if (request.config?.tools) { openaiRequest.tools = - await OpenAIContentConverter.convertGeminiToolsToOpenAI( + await OpenAIContentConverter.convertLlmToolsToOpenAI( request.config.tools, this.schemaCompliance ?? 'auto', ); @@ -1043,7 +1043,7 @@ export class LoggingContentGenerator implements ContentGenerator { } const openaiResponse = response - ? this.convertGeminiResponseToOpenAIForLogging(response, openaiRequest) + ? this.convertLlmResponseToOpenAIForLogging(response, openaiRequest) : undefined; await this.openaiLogger.logInteraction( @@ -1071,17 +1071,17 @@ export class LoggingContentGenerator implements ContentGenerator { } } - private convertGeminiResponseToOpenAIForLogging( + private convertLlmResponseToOpenAIForLogging( response: GenerateContentResponse, openaiRequest: OpenAI.Chat.ChatCompletionCreateParams, ): OpenAI.Chat.ChatCompletion { - return OpenAIContentConverter.convertGeminiResponseToOpenAI( + return OpenAIContentConverter.convertLlmResponseToOpenAI( response, this.createLoggingRequestContext(openaiRequest.model), ); } - private consolidateGeminiResponsesForLogging( + private consolidateLlmResponsesForLogging( responses: GenerateContentResponse[], ): GenerateContentResponse | undefined { if (responses.length === 0) { diff --git a/packages/core/src/core/nonInteractiveToolExecutor.test.ts b/packages/core/src/core/nonInteractiveToolExecutor.test.ts index 0ef4e7965b0..789dd5c63c2 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.test.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.test.ts @@ -63,7 +63,7 @@ describe('executeToolCall', () => { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getUseModelRouter: () => false, - getGeminiClient: () => null, // No client needed for these tests + getLlmClient: () => null, // No client needed for these tests getChatRecordingService: () => undefined, getMessageBus: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), diff --git a/packages/core/src/core/openaiContentGenerator/converter.test.ts b/packages/core/src/core/openaiContentGenerator/converter.test.ts index ce120af2fc4..c889f6ed3e4 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.test.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.test.ts @@ -111,7 +111,7 @@ describe('OpenAIContentConverter', () => { }) as unknown as OpenAI.Chat.ChatCompletionChunk; const emitReasoning = (stream: RequestContext) => - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('reasoning', { reasoning_content: 'Let me check.' }), stream, ); @@ -121,7 +121,7 @@ describe('OpenAIContentConverter', () => { content: string, toolArguments = '{}', ) => - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('tool-call', { content, tool_calls: [ @@ -139,7 +139,7 @@ describe('OpenAIContentConverter', () => { stream: RequestContext, finishReason = 'tool_calls', ) => - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('finish', {}, finishReason), stream, ); @@ -154,7 +154,7 @@ describe('OpenAIContentConverter', () => { }); it('preserves the provider model from stream chunks', () => { - const response = converter.convertOpenAIChunkToGemini( + const response = converter.convertOpenAIChunkToLlm( streamChunk('model', { content: 'ok' }), withStreamParser(), ); @@ -321,16 +321,16 @@ describe('OpenAIContentConverter', () => { // Interleave the two streams. Pre-fix this produced corrupt JSON // because every chunk fed the same shared parser. - converter.convertOpenAIChunkToGemini(openerA, streamA); - converter.convertOpenAIChunkToGemini(openerB, streamB); - converter.convertOpenAIChunkToGemini(contA, streamA); - converter.convertOpenAIChunkToGemini(contB, streamB); + converter.convertOpenAIChunkToLlm(openerA, streamA); + converter.convertOpenAIChunkToLlm(openerB, streamB); + converter.convertOpenAIChunkToLlm(contA, streamA); + converter.convertOpenAIChunkToLlm(contB, streamB); - const resultA = converter.convertOpenAIChunkToGemini( + const resultA = converter.convertOpenAIChunkToLlm( finisher('A-finish'), streamA, ); - const resultB = converter.convertOpenAIChunkToGemini( + const resultB = converter.convertOpenAIChunkToLlm( finisher('B-finish'), streamB, ); @@ -397,8 +397,8 @@ describe('OpenAIContentConverter', () => { ], } as unknown as OpenAI.Chat.ChatCompletionChunk; - converter.convertOpenAIChunkToGemini(opener, stream); - const result = converter.convertOpenAIChunkToGemini(finisher, stream); + converter.convertOpenAIChunkToLlm(opener, stream); + const result = converter.convertOpenAIChunkToLlm(finisher, stream); const fn = result.candidates?.[0]?.content?.parts?.find( (p: Part) => p.functionCall, @@ -411,7 +411,7 @@ describe('OpenAIContentConverter', () => { it('ignores a phantom slot beside a valid tool call', () => { const stream = withStreamParser(); - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('open', { tool_calls: [ { @@ -425,7 +425,7 @@ describe('OpenAIContentConverter', () => { stream, ); - const result = converter.convertOpenAIChunkToGemini( + const result = converter.convertOpenAIChunkToLlm( streamChunk('finish', {}, 'tool_calls'), stream, ); @@ -440,13 +440,13 @@ describe('OpenAIContentConverter', () => { it('rejects a tool-call finish without a completed named call', () => { const stream = withStreamParser(); - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('open', { tool_calls: [{ index: 0, function: {} }] }), stream, ); expect(() => - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('finish', {}, 'tool_calls'), stream, ), @@ -455,7 +455,7 @@ describe('OpenAIContentConverter', () => { it('rejects a tool call that never provides a function name', () => { const stream = withStreamParser(); - const partial = converter.convertOpenAIChunkToGemini( + const partial = converter.convertOpenAIChunkToLlm( streamChunk('open', { content: 'discard me', tool_calls: [ @@ -471,7 +471,7 @@ describe('OpenAIContentConverter', () => { expect(partial.candidates?.[0]?.content?.parts).toEqual([]); expect(() => - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('finish', {}, 'stop'), stream, ), @@ -481,7 +481,7 @@ describe('OpenAIContentConverter', () => { it('rejects a protocol-tag recovery with a whitespace-only function name', () => { const stream = withStreamParser(); emitReasoning(stream); - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('tool-call', { content: '', tool_calls: [ @@ -503,7 +503,7 @@ describe('OpenAIContentConverter', () => { it('rejects the recorded cross-channel thinking-tag leak', () => { const stream = withStreamParser(); - const reasoning = converter.convertOpenAIChunkToGemini( + const reasoning = converter.convertOpenAIChunkToLlm( streamChunk('reasoning', { reasoning_content: 'Let me check', }), @@ -512,7 +512,7 @@ describe('OpenAIContentConverter', () => { expect(reasoning.candidates?.[0]?.content?.parts).toEqual([]); expect(() => - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('content', { content: 'the result\n\n' }), stream, ), @@ -522,18 +522,18 @@ describe('OpenAIContentConverter', () => { it('rejects the recorded content-only nested thinking-tag leak', () => { const stream = withStreamParser(); stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true }; - const opening = converter.convertOpenAIChunkToGemini( + const opening = converter.convertOpenAIChunkToLlm( streamChunk('opening', { content: '\n\n' }), stream, ); - const repeatedOpening = converter.convertOpenAIChunkToGemini( + const repeatedOpening = converter.convertOpenAIChunkToLlm( streamChunk('repeated-opening', { content: '9-3' }), stream, ); @@ -551,11 +551,11 @@ describe('OpenAIContentConverter', () => { // any chunk, no tool calls, and the tag is never closed before stop. const stream = withStreamParser(); stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true }; - const opening = converter.convertOpenAIChunkToGemini( + const opening = converter.convertOpenAIChunkToLlm( streamChunk('opening', { content: '\nThe user wants to query the compute resources for ' + @@ -576,13 +576,13 @@ describe('OpenAIContentConverter', () => { stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true }; const text = `${'x'.repeat(200)}`; - const opening = converter.convertOpenAIChunkToGemini( + const opening = converter.convertOpenAIChunkToLlm( streamChunk('long-balanced', { content: `${'x'.repeat(200)}`, }), stream, ); - const closing = converter.convertOpenAIChunkToGemini( + const closing = converter.convertOpenAIChunkToLlm( streamChunk('long-balanced', { content: '' }, 'stop'), stream, ); @@ -596,7 +596,7 @@ describe('OpenAIContentConverter', () => { // same stream passes through verbatim — the defense is provider-gated, // so endpoints whose provider does not opt in remain exposed. const stream = withStreamParser(); - const response = converter.convertOpenAIChunkToGemini( + const response = converter.convertOpenAIChunkToLlm( streamChunk( 'literal', { @@ -627,7 +627,7 @@ describe('OpenAIContentConverter', () => { const stream = withStreamParser(); stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true }; const parts = chunks.flatMap((content, index) => { - const response = converter.convertOpenAIChunkToGemini( + const response = converter.convertOpenAIChunkToLlm( streamChunk( `literal-${index}`, { content }, @@ -647,7 +647,7 @@ describe('OpenAIContentConverter', () => { stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true }; const text = ` { it('rejects an unclosed whitespace-only block at stream finish', () => { const stream = withStreamParser(); stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true }; - const response = converter.convertOpenAIChunkToGemini( + const response = converter.convertOpenAIChunkToLlm( streamChunk('unclosed', { content: `${' '.repeat(128)}`, }), @@ -674,7 +674,7 @@ describe('OpenAIContentConverter', () => { it('preserves a leak-shaped literal without provider provenance', () => { const stream = withStreamParser(); const text = '\n\n9-3'; - const response = converter.convertOpenAIChunkToGemini( + const response = converter.convertOpenAIChunkToLlm( streamChunk('literal', { content: text }, 'stop'), stream, ); @@ -698,7 +698,7 @@ describe('OpenAIContentConverter', () => { const stream = withStreamParser(); stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true }; const parts = chunks.flatMap((content, index) => { - const response = converter.convertOpenAIChunkToGemini( + const response = converter.convertOpenAIChunkToLlm( streamChunk( `balanced-${index}`, { content }, @@ -717,7 +717,7 @@ describe('OpenAIContentConverter', () => { stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true }; expect(() => - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk( 'nested-unclosed', { @@ -735,7 +735,7 @@ describe('OpenAIContentConverter', () => { stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true }; const content = '9' + 'x'.repeat(257); - const response = converter.convertOpenAIChunkToGemini( + const response = converter.convertOpenAIChunkToLlm( streamChunk('long-leak', { content }), stream, ); @@ -754,7 +754,7 @@ describe('OpenAIContentConverter', () => { stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true }; expect(() => - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('variant', { content }, 'stop'), stream, ), @@ -763,7 +763,7 @@ describe('OpenAIContentConverter', () => { it('rejects closing-tag recovery after a tag leaked in reasoning', () => { const stream = withStreamParser(); - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('reasoning', { reasoning_content: 'Let me check', }), @@ -807,7 +807,7 @@ describe('OpenAIContentConverter', () => { it('sanitizes a standalone closing thinking tag split across chunks', () => { const stream = withStreamParser(); emitReasoning(stream); - const firstHalf = converter.convertOpenAIChunkToGemini( + const firstHalf = converter.convertOpenAIChunkToLlm( streamChunk('tag-start', { content: '\n { it('sanitizes a standalone closing thinking tag with multiple tool calls', () => { const stream = withStreamParser(); emitReasoning(stream); - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('tool-calls', { content: '', tool_calls: [ @@ -867,7 +867,7 @@ describe('OpenAIContentConverter', () => { it('recovers complete same-index tool calls with distinct IDs', () => { const stream = withStreamParser(); emitReasoning(stream); - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('tool-calls', { content: '', tool_calls: [ @@ -913,7 +913,7 @@ describe('OpenAIContentConverter', () => { (_state, oldArguments, newArguments) => { const stream = withStreamParser(); emitReasoning(stream); - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('old-arguments', { content: '', tool_calls: [ @@ -926,7 +926,7 @@ describe('OpenAIContentConverter', () => { }), stream, ); - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('new-name', { tool_calls: [ { @@ -949,7 +949,7 @@ describe('OpenAIContentConverter', () => { it('buffers leading whitespace before a split standalone closing tag', () => { const stream = withStreamParser(); emitReasoning(stream); - const whitespace = converter.convertOpenAIChunkToGemini( + const whitespace = converter.convertOpenAIChunkToLlm( streamChunk('whitespace', { content: '\n' }), stream, ); @@ -974,7 +974,7 @@ describe('OpenAIContentConverter', () => { const whitespace = ' '.repeat(129); emitReasoning(stream); - const pending = converter.convertOpenAIChunkToGemini( + const pending = converter.convertOpenAIChunkToLlm( streamChunk('whitespace', { content: whitespace }), stream, ); @@ -989,7 +989,7 @@ describe('OpenAIContentConverter', () => { it('emits trailing whitespace when the stream finishes', () => { const stream = withStreamParser(); emitReasoning(stream); - const whitespace = converter.convertOpenAIChunkToGemini( + const whitespace = converter.convertOpenAIChunkToLlm( streamChunk('whitespace', { content: ' \n' }), stream, ); @@ -1003,11 +1003,11 @@ describe('OpenAIContentConverter', () => { it('ignores an exact cumulative replay of a deferred closing tag', () => { const stream = withStreamParser(); emitReasoning(stream); - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('tag', { content: '' }), stream, ); - const finish = converter.convertOpenAIChunkToGemini( + const finish = converter.convertOpenAIChunkToLlm( streamChunk( 'finish', { @@ -1039,11 +1039,11 @@ describe('OpenAIContentConverter', () => { it('ignores cumulative replays of an incomplete closing tag', () => { const stream = withStreamParser(); emitReasoning(stream); - const first = converter.convertOpenAIChunkToGemini( + const first = converter.convertOpenAIChunkToLlm( streamChunk('tag-prefix', { content: ' { it('rejects an invalid tool-call index on a stop finish', () => { const stream = withStreamParser(); - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('invalid-tool-call', { tool_calls: [ { @@ -1086,7 +1086,7 @@ describe('OpenAIContentConverter', () => { it('rejects valid tool calls accompanied by an invalid index', () => { const stream = withStreamParser(); - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('mixed-tool-calls', { tool_calls: [ { @@ -1111,15 +1111,15 @@ describe('OpenAIContentConverter', () => { it('releases a split tag-like prefix when it becomes ordinary text', () => { const stream = withStreamParser(); - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('reasoning', { reasoning_content: 'Explain the syntax.' }), stream, ); - const prefix = converter.convertOpenAIChunkToGemini( + const prefix = converter.convertOpenAIChunkToLlm( streamChunk('prefix', { content: ' { emitToolCall(stream, ''); for (let i = 0; i < 1_000; i++) { - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk(`whitespace-${i}`, { content: ' ' }), stream, ); @@ -1157,7 +1157,7 @@ describe('OpenAIContentConverter', () => { it('rejects a standalone closing thinking tag without a complete tool call', () => { const stream = withStreamParser(); emitReasoning(stream); - const leakedTag = converter.convertOpenAIChunkToGemini( + const leakedTag = converter.convertOpenAIChunkToLlm( streamChunk('content', { content: '' }), stream, ); @@ -1186,13 +1186,13 @@ describe('OpenAIContentConverter', () => { it('rejects visible content after a deferred closing thinking tag', () => { const stream = withStreamParser(); emitReasoning(stream); - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('content', { content: '' }), stream, ); expect(() => - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('content-after-tag', { content: 'unexpected' }), stream, ), @@ -1216,23 +1216,23 @@ describe('OpenAIContentConverter', () => { it('rejects a closing tag split after a visible line break', () => { const stream = withStreamParser(); - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('reasoning', { reasoning_content: 'Let me check', }), stream, ); - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('content', { content: 'the result\n' }), stream, ); - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('blank-lines', { content: '\n'.repeat(256) }), stream, ); expect(() => - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( streamChunk('closing-tag', { content: '\n' }), stream, ), @@ -1241,15 +1241,15 @@ describe('OpenAIContentConverter', () => { it('preserves a split literal closing tag after ordinary reasoning', () => { const stream = withStreamParser(); - const reasoning = converter.convertOpenAIChunkToGemini( + const reasoning = converter.convertOpenAIChunkToLlm( streamChunk('reasoning', { reasoning_content: 'Explain the syntax.' }), stream, ); - const prefix = converter.convertOpenAIChunkToGemini( + const prefix = converter.convertOpenAIChunkToLlm( streamChunk('prefix', { content: 'Use ' }), stream, ); - const closingTag = converter.convertOpenAIChunkToGemini( + const closingTag = converter.convertOpenAIChunkToLlm( streamChunk('closing-tag', { content: ' to close the tag.' }), stream, ); @@ -1267,17 +1267,17 @@ describe('OpenAIContentConverter', () => { it('releases inline thinking-tag references in both channels', () => { const stream = withStreamParser(); - const reasoning = converter.convertOpenAIChunkToGemini( + const reasoning = converter.convertOpenAIChunkToLlm( streamChunk('reasoning', { reasoning_content: 'The format may contain tags.', }), stream, ); - const content = converter.convertOpenAIChunkToGemini( + const content = converter.convertOpenAIChunkToLlm( streamChunk('content', { content: 'Use to close the tag.' }), stream, ); - const finish = converter.convertOpenAIChunkToGemini( + const finish = converter.convertOpenAIChunkToLlm( streamChunk('finish', {}, 'stop'), stream, ); @@ -1291,7 +1291,7 @@ describe('OpenAIContentConverter', () => { }); }); - describe('convertGeminiRequestToOpenAI', () => { + describe('convertLlmRequestToOpenAI', () => { const createRequestWithFunctionResponse = ( response: Record, ): GenerateContentParameters => { @@ -1358,7 +1358,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -1383,7 +1383,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI(request, { + const messages = converter.convertLlmRequestToOpenAI(request, { ...requestContext, splitToolMedia: true, }); @@ -1404,7 +1404,7 @@ describe('OpenAIContentConverter', () => { output: 'Raw output text', }); - const messages = converter.convertGeminiRequestToOpenAI(request, { + const messages = converter.convertLlmRequestToOpenAI(request, { ...requestContext, splitToolMedia: true, }); @@ -1425,7 +1425,7 @@ describe('OpenAIContentConverter', () => { error: 'Command failed', }); - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -1446,7 +1446,7 @@ describe('OpenAIContentConverter', () => { data: { value: 42 }, }); - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -1501,7 +1501,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -1578,7 +1578,7 @@ describe('OpenAIContentConverter', () => { ...requestContext, splitToolMedia: true, }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, strictContext, ); @@ -1668,7 +1668,7 @@ describe('OpenAIContentConverter', () => { ...requestContext, splitToolMedia: true, }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, strictContext, ); @@ -1770,7 +1770,7 @@ describe('OpenAIContentConverter', () => { ...requestContext, splitToolMedia: true, }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, strictContext, ); @@ -1824,7 +1824,7 @@ describe('OpenAIContentConverter', () => { ...requestContext, splitToolMedia: true, }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, strictContext, ); @@ -1870,7 +1870,7 @@ describe('OpenAIContentConverter', () => { ...requestContext, splitToolMedia: true, }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, strictContext, ); @@ -1929,7 +1929,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI(request, { + const messages = converter.convertLlmRequestToOpenAI(request, { ...requestContext, splitToolMedia: false, }); @@ -1973,7 +1973,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI(request, { + const messages = converter.convertLlmRequestToOpenAI(request, { ...requestContext, splitToolMedia: false, toolResultContentFormat: 'string', @@ -2033,7 +2033,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -2098,7 +2098,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -2165,7 +2165,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -2234,7 +2234,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -2297,7 +2297,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -2360,7 +2360,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -2427,7 +2427,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -2489,7 +2489,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -2550,7 +2550,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -2576,7 +2576,7 @@ describe('OpenAIContentConverter', () => { output: 'Plain text output', }); - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -2602,7 +2602,7 @@ describe('OpenAIContentConverter', () => { output: 'Plain text output', }); - const messages = converter.convertGeminiRequestToOpenAI(request, { + const messages = converter.convertLlmRequestToOpenAI(request, { ...requestContext, toolResultContentFormat: 'string', }); @@ -2648,7 +2648,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -2744,7 +2744,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -2794,7 +2794,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -2835,7 +2835,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -2901,7 +2901,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -2960,7 +2960,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -3017,7 +3017,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI(request, { + const messages = converter.convertLlmRequestToOpenAI(request, { ...requestContext, splitToolMedia: true, }); @@ -3071,7 +3071,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -3120,7 +3120,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -3192,7 +3192,7 @@ describe('OpenAIContentConverter', () => { splitToolMedia: true, }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, strictContext, ); @@ -3258,7 +3258,7 @@ describe('OpenAIContentConverter', () => { splitToolMedia: true, }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, strictContext, ); @@ -3336,7 +3336,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -3381,7 +3381,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -3418,7 +3418,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -3464,7 +3464,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -3490,7 +3490,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -3556,7 +3556,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -3627,7 +3627,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -3695,7 +3695,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -3715,9 +3715,9 @@ describe('OpenAIContentConverter', () => { }); }); - describe('convertOpenAIResponseToGemini', () => { + describe('convertOpenAIResponseToLlm', () => { it('should handle empty choices array without crashing', () => { - const response = converter.convertOpenAIResponseToGemini( + const response = converter.convertOpenAIResponseToLlm( { object: 'chat.completion', id: 'chatcmpl-empty', @@ -3733,7 +3733,7 @@ describe('OpenAIContentConverter', () => { }); it('maps uppercase finish_reason values case-insensitively', () => { - const stop = converter.convertOpenAIResponseToGemini( + const stop = converter.convertOpenAIResponseToLlm( { object: 'chat.completion', id: 'chatcmpl-stop', @@ -3750,7 +3750,7 @@ describe('OpenAIContentConverter', () => { } as unknown as OpenAI.Chat.ChatCompletion, requestContext, ); - const truncated = converter.convertOpenAIResponseToGemini( + const truncated = converter.convertOpenAIResponseToLlm( { object: 'chat.completion', id: 'chatcmpl-max-tokens', @@ -3775,7 +3775,7 @@ describe('OpenAIContentConverter', () => { }); it('does not throw on a non-string finish_reason from a malformed gateway', () => { - const response = converter.convertOpenAIResponseToGemini( + const response = converter.convertOpenAIResponseToLlm( { object: 'chat.completion', id: 'chatcmpl-malformed', @@ -3799,7 +3799,7 @@ describe('OpenAIContentConverter', () => { }); it('omits the input/output breakdown when only total tokens are reported', () => { - const response = converter.convertOpenAIResponseToGemini( + const response = converter.convertOpenAIResponseToLlm( { object: 'chat.completion', id: 'chatcmpl-usage', @@ -3828,7 +3828,7 @@ describe('OpenAIContentConverter', () => { }); it('omits the streaming input/output breakdown when only total tokens are reported', () => { - const response = converter.convertOpenAIChunkToGemini( + const response = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-usage', @@ -3857,14 +3857,14 @@ describe('OpenAIContentConverter', () => { model: 'provider-model', choices: [], } as const; - const absent = converter.convertOpenAIResponseToGemini( + const absent = converter.convertOpenAIResponseToLlm( { ...base, usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 }, } as unknown as OpenAI.Chat.ChatCompletion, requestContext, ); - const zero = converter.convertOpenAIResponseToGemini( + const zero = converter.convertOpenAIResponseToLlm( { ...base, usage: { @@ -3888,7 +3888,7 @@ describe('OpenAIContentConverter', () => { }); it('estimates missing reasoning tokens from non-streaming content', () => { - const response = converter.convertOpenAIResponseToGemini( + const response = converter.convertOpenAIResponseToLlm( { object: 'chat.completion', id: 'chatcmpl-reasoning-usage', @@ -3915,7 +3915,7 @@ describe('OpenAIContentConverter', () => { }); it('estimates missing reasoning tokens from non-streaming reasoning field', () => { - const response = converter.convertOpenAIResponseToGemini( + const response = converter.convertOpenAIResponseToLlm( { object: 'chat.completion', id: 'chatcmpl-reasoning-field-usage', @@ -3942,7 +3942,7 @@ describe('OpenAIContentConverter', () => { }); it('clamps estimated non-streaming reasoning tokens to completion tokens', () => { - const response = converter.convertOpenAIResponseToGemini( + const response = converter.convertOpenAIResponseToLlm( { object: 'chat.completion', id: 'chatcmpl-reasoning-clamped-usage', @@ -3971,7 +3971,7 @@ describe('OpenAIContentConverter', () => { it.each([0, 42])( 'preserves provider reasoning tokens for non-streaming content: %s', (reasoningTokens) => { - const response = converter.convertOpenAIResponseToGemini( + const response = converter.convertOpenAIResponseToLlm( { object: 'chat.completion', id: 'chatcmpl-provider-reasoning-usage', @@ -4025,15 +4025,15 @@ describe('OpenAIContentConverter', () => { ], }) as unknown as OpenAI.Chat.ChatCompletionChunk; - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( reasoningChunk('chunk-reasoning-1', '想'.repeat(1024)), context, ); - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( reasoningChunk('chunk-reasoning-2', '想'), context, ); - const response = converter.convertOpenAIChunkToGemini( + const response = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-reasoning-usage', @@ -4070,15 +4070,15 @@ describe('OpenAIContentConverter', () => { ], }) as unknown as OpenAI.Chat.ChatCompletionChunk; - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( reasoningChunk('chunk-short-reasoning-1', '先'), context, ); - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( reasoningChunk('chunk-short-reasoning-2', '仔细想'), context, ); - const response = converter.convertOpenAIChunkToGemini( + const response = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-short-reasoning-usage', @@ -4096,7 +4096,7 @@ describe('OpenAIContentConverter', () => { it('estimates normalized cumulative reasoning without a completion count', () => { const context = withStreamParser(); for (const reasoning_content of ['先仔细想', '先仔细想再检查']) { - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-cumulative-reasoning', @@ -4114,7 +4114,7 @@ describe('OpenAIContentConverter', () => { context, ); } - const response = converter.convertOpenAIChunkToGemini( + const response = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-cumulative-reasoning-usage', @@ -4131,7 +4131,7 @@ describe('OpenAIContentConverter', () => { it('clamps estimated streaming reasoning tokens to completion tokens', () => { const context = withStreamParser(); - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-clamped-reasoning', @@ -4148,7 +4148,7 @@ describe('OpenAIContentConverter', () => { } as unknown as OpenAI.Chat.ChatCompletionChunk, context, ); - const response = converter.convertOpenAIChunkToGemini( + const response = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-clamped-reasoning-usage', @@ -4167,7 +4167,7 @@ describe('OpenAIContentConverter', () => { 'preserves provider reasoning tokens for streaming content: %s', (reasoningTokens) => { const context = withStreamParser(); - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-provider-reasoning', @@ -4184,7 +4184,7 @@ describe('OpenAIContentConverter', () => { } as unknown as OpenAI.Chat.ChatCompletionChunk, context, ); - const response = converter.convertOpenAIChunkToGemini( + const response = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-provider-reasoning-usage', @@ -4212,7 +4212,7 @@ describe('OpenAIContentConverter', () => { describe('OpenAI -> Gemini reasoning content', () => { it('should convert reasoning_content to a thought part for non-streaming responses', () => { - const response = converter.convertOpenAIResponseToGemini( + const response = converter.convertOpenAIResponseToLlm( { object: 'chat.completion', id: 'chatcmpl-1', @@ -4245,7 +4245,7 @@ describe('OpenAIContentConverter', () => { }); it('should convert reasoning to a thought part for non-streaming responses', () => { - const response = converter.convertOpenAIResponseToGemini( + const response = converter.convertOpenAIResponseToLlm( { object: 'chat.completion', id: 'chatcmpl-2', @@ -4278,7 +4278,7 @@ describe('OpenAIContentConverter', () => { }); it('should convert streaming reasoning_content delta to a thought part', () => { - const chunk = converter.convertOpenAIChunkToGemini( + const chunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-1', @@ -4310,7 +4310,7 @@ describe('OpenAIContentConverter', () => { }); it('should convert streaming reasoning delta to a thought part', () => { - const chunk = converter.convertOpenAIChunkToGemini( + const chunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-1b', @@ -4341,7 +4341,7 @@ describe('OpenAIContentConverter', () => { }); it('should not throw when streaming chunk has no delta', () => { - const chunk = converter.convertOpenAIChunkToGemini( + const chunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-2', @@ -4374,7 +4374,7 @@ describe('OpenAIContentConverter', () => { ]; const emitted = chunks.map((content, index) => { - const chunk = converter.convertOpenAIChunkToGemini( + const chunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: `chunk-cumulative-${index}`, @@ -4413,7 +4413,7 @@ describe('OpenAIContentConverter', () => { const content = 'The following section starts with more than enough text for cumulative-mode detection.'; const emitted = [content, content].map((chunkContent, index) => { - const chunk = converter.convertOpenAIChunkToGemini( + const chunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: `chunk-cumulative-repeat-${index}`, @@ -4440,7 +4440,7 @@ describe('OpenAIContentConverter', () => { it('should preserve repeated short incremental content chunks', () => { const ctx = withStreamParser(); const emitted = ['ha', 'ha'].map((content, index) => { - const chunk = converter.convertOpenAIChunkToGemini( + const chunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: `chunk-repeat-${index}`, @@ -4473,7 +4473,7 @@ describe('OpenAIContentConverter', () => { ]; const emitted = chunks.map((reasoning_content, index) => { - const chunk = converter.convertOpenAIChunkToGemini( + const chunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: `chunk-reasoning-cumulative-${index}`, @@ -4515,7 +4515,7 @@ describe('OpenAIContentConverter', () => { ]; const emitted = chunks.map((content, index) => { - const chunk = converter.convertOpenAIChunkToGemini( + const chunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: `chunk-cumulative-exit-${index}`, @@ -4558,7 +4558,7 @@ describe('OpenAIContentConverter', () => { const emitted = chunks.map( (content, index) => - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: `chunk-reentry-${index}`, @@ -4593,7 +4593,7 @@ describe('OpenAIContentConverter', () => { const emitted = chunks.map( (content, index) => - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: `chunk-short-repeat-${index}`, @@ -4633,7 +4633,7 @@ describe('OpenAIContentConverter', () => { ]; const emitted = chunks.map((reasoning_content, index) => { - const part = converter.convertOpenAIChunkToGemini( + const part = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: `chunk-reasoning-cumulative2-${index}`, @@ -4676,7 +4676,7 @@ describe('OpenAIContentConverter', () => { const reasoning = 'The reasoning section also starts with more than enough text to pass detection.'; const emitted = [reasoning, reasoning].map((reasoning_content, index) => { - const part = converter.convertOpenAIChunkToGemini( + const part = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: `chunk-reasoning-repeat-${index}`, @@ -4713,7 +4713,7 @@ describe('OpenAIContentConverter', () => { ]; const emitted = chunks.map((reasoning_content, index) => { - const part = converter.convertOpenAIChunkToGemini( + const part = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: `chunk-reasoning-exit-${index}`, @@ -4765,7 +4765,7 @@ describe('OpenAIContentConverter', () => { ]; const emitted = chunks.map((reasoning_content, index) => { - const part = converter.convertOpenAIChunkToGemini( + const part = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: `chunk-reasoning-reentry-${index}`, @@ -4818,7 +4818,7 @@ describe('OpenAIContentConverter', () => { const emitted = chunks.map( (delta, index) => - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: `chunk-interleaved-${index}`, @@ -4860,7 +4860,7 @@ describe('OpenAIContentConverter', () => { const emitted = chunks.map( (content, index) => - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: `chunk-threshold64-${index}`, @@ -4899,7 +4899,7 @@ describe('OpenAIContentConverter', () => { const emitted = chunks.map( (content, index) => - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: `chunk-threshold63-${index}`, @@ -4939,7 +4939,7 @@ describe('OpenAIContentConverter', () => { const emitted = chunks.map( (content, index) => - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: `chunk-import-${index}`, @@ -4989,7 +4989,7 @@ describe('OpenAIContentConverter', () => { ); const allEmitted = incrementalChunks.map( (content, index) => - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: `chunk-cap-${index}`, @@ -5027,7 +5027,7 @@ describe('OpenAIContentConverter', () => { const emitted = [firstChunk, secondChunk, thirdChunk].map( (content, index) => - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: `chunk-large-first-${index}`, @@ -5078,7 +5078,7 @@ describe('OpenAIContentConverter', () => { const incrementalEmitted = incremental.map( (content, index) => - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: `chunk-hybrid-incr-${index}`, @@ -5098,7 +5098,7 @@ describe('OpenAIContentConverter', () => { ); const cumulativeEmitted = - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-hybrid-cum', @@ -5134,7 +5134,7 @@ describe('OpenAIContentConverter', () => { const emitted = chunks.map( (content, index) => - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: `chunk-rewind-${index}`, @@ -5174,7 +5174,7 @@ describe('OpenAIContentConverter', () => { const ctx = withStreamParser(); // 1) Prefix-extension chunk pair establishes cumulative mode and primes // `emittedText` so the next exact-repeat is the cumulative branch. - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-cum-empty-finish-0', @@ -5191,7 +5191,7 @@ describe('OpenAIContentConverter', () => { } as unknown as OpenAI.Chat.ChatCompletionChunk, ctx, ); - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-cum-empty-finish-1', @@ -5213,7 +5213,7 @@ describe('OpenAIContentConverter', () => { // `finish_reason: 'stop'`. The normalized delta is '' (cumulative // suffix-of-self), but the finish_reason must still drive // convertOpenAITextToParts. - const finalChunk = converter.convertOpenAIChunkToGemini( + const finalChunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-cum-empty-finish-2', @@ -5249,7 +5249,7 @@ describe('OpenAIContentConverter', () => { const ctx = withStreamParser(); ctx.responseParsingOptions = { contentOnlyThinkingTagLeaks: true }; const part = - converter.convertOpenAIChunkToGemini( + converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-dual-1', @@ -5280,7 +5280,7 @@ describe('OpenAIContentConverter', () => { describe('OpenAI -> Gemini tagged thinking content', () => { it('should convert MiniMax content to thought parts for non-streaming responses', () => { - const response = converter.convertOpenAIResponseToGemini( + const response = converter.convertOpenAIResponseToLlm( { object: 'chat.completion', id: 'chatcmpl-minimax-1', @@ -5308,7 +5308,7 @@ describe('OpenAIContentConverter', () => { }); it('should preserve ordering around blocks', () => { - const response = converter.convertOpenAIResponseToGemini( + const response = converter.convertOpenAIResponseToLlm( { object: 'chat.completion', id: 'chatcmpl-minimax-2', @@ -5337,7 +5337,7 @@ describe('OpenAIContentConverter', () => { }); it('should parse multiple tagged thinking blocks case-insensitively', () => { - const response = converter.convertOpenAIResponseToGemini( + const response = converter.convertOpenAIResponseToLlm( { object: 'chat.completion', id: 'chatcmpl-minimax-3', @@ -5366,7 +5366,7 @@ describe('OpenAIContentConverter', () => { }); it('should leave tags visible when tagged thinking parsing is disabled', () => { - const response = converter.convertOpenAIResponseToGemini( + const response = converter.convertOpenAIResponseToLlm( { object: 'chat.completion', id: 'chatcmpl-openai-1', @@ -5396,7 +5396,7 @@ describe('OpenAIContentConverter', () => { }); it('should preserve incomplete tags as visible text on final non-streaming parse', () => { - const response = converter.convertOpenAIResponseToGemini( + const response = converter.convertOpenAIResponseToLlm( { object: 'chat.completion', id: 'chatcmpl-minimax-4', @@ -5425,7 +5425,7 @@ describe('OpenAIContentConverter', () => { it('should parse streaming tags split across chunks', () => { const context = withTaggedThinkingStreamParser(); - const firstChunk = converter.convertOpenAIChunkToGemini( + const firstChunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-minimax-1', @@ -5442,7 +5442,7 @@ describe('OpenAIContentConverter', () => { } as unknown as OpenAI.Chat.ChatCompletionChunk, context, ); - const secondChunk = converter.convertOpenAIChunkToGemini( + const secondChunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-minimax-2', @@ -5459,7 +5459,7 @@ describe('OpenAIContentConverter', () => { } as unknown as OpenAI.Chat.ChatCompletionChunk, context, ); - const finalChunk = converter.convertOpenAIChunkToGemini( + const finalChunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-minimax-3', @@ -5491,7 +5491,7 @@ describe('OpenAIContentConverter', () => { it('should suppress reasoning_content when the same streaming chunk has tagged thinking content', () => { const context = withTaggedThinkingStreamParser(); - const chunk = converter.convertOpenAIChunkToGemini( + const chunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-glm-dual-tagged', @@ -5521,7 +5521,7 @@ describe('OpenAIContentConverter', () => { it('should suppress late reasoning_content after streaming tagged thinking content', () => { const context = withTaggedThinkingStreamParser(); - const firstChunk = converter.convertOpenAIChunkToGemini( + const firstChunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-glm-late-reasoning-1', @@ -5538,7 +5538,7 @@ describe('OpenAIContentConverter', () => { } as unknown as OpenAI.Chat.ChatCompletionChunk, context, ); - const secondChunk = converter.convertOpenAIChunkToGemini( + const secondChunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-glm-late-reasoning-2', @@ -5565,7 +5565,7 @@ describe('OpenAIContentConverter', () => { it('should suppress buffered reasoning_content when later streaming content has tagged thinking', () => { const context = withTaggedThinkingStreamParser(); - const firstChunk = converter.convertOpenAIChunkToGemini( + const firstChunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-glm-buffered-reasoning-1', @@ -5582,7 +5582,7 @@ describe('OpenAIContentConverter', () => { } as unknown as OpenAI.Chat.ChatCompletionChunk, context, ); - const secondChunk = converter.convertOpenAIChunkToGemini( + const secondChunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-glm-buffered-reasoning-2', @@ -5599,7 +5599,7 @@ describe('OpenAIContentConverter', () => { } as unknown as OpenAI.Chat.ChatCompletionChunk, context, ); - const finalChunk = converter.convertOpenAIChunkToGemini( + const finalChunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-glm-buffered-reasoning-3', @@ -5629,7 +5629,7 @@ describe('OpenAIContentConverter', () => { it('should flush buffered content before later tagged thinking content', () => { const context = withTaggedThinkingStreamParser(); - const firstChunk = converter.convertOpenAIChunkToGemini( + const firstChunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-glm-buffered-content-before-tag-1', @@ -5649,7 +5649,7 @@ describe('OpenAIContentConverter', () => { } as unknown as OpenAI.Chat.ChatCompletionChunk, context, ); - const secondChunk = converter.convertOpenAIChunkToGemini( + const secondChunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-glm-buffered-content-before-tag-2', @@ -5678,7 +5678,7 @@ describe('OpenAIContentConverter', () => { it('should flush buffered content before current content when reasoning flushes on finish', () => { const context = withTaggedThinkingStreamParser(); - const firstChunk = converter.convertOpenAIChunkToGemini( + const firstChunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-glm-buffered-content-order-1', @@ -5698,7 +5698,7 @@ describe('OpenAIContentConverter', () => { } as unknown as OpenAI.Chat.ChatCompletionChunk, context, ); - const finalChunk = converter.convertOpenAIChunkToGemini( + const finalChunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-glm-buffered-content-order-2', @@ -5727,7 +5727,7 @@ describe('OpenAIContentConverter', () => { it('should flush buffered reasoning_content when tagged streaming content has no thinking tags', () => { const context = withTaggedThinkingStreamParser(); - const firstChunk = converter.convertOpenAIChunkToGemini( + const firstChunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-glm-reasoning-only-1', @@ -5747,7 +5747,7 @@ describe('OpenAIContentConverter', () => { } as unknown as OpenAI.Chat.ChatCompletionChunk, context, ); - const finalChunk = converter.convertOpenAIChunkToGemini( + const finalChunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-glm-reasoning-only-2', @@ -5779,7 +5779,7 @@ describe('OpenAIContentConverter', () => { it('should flush reasoning-only chunks when tagged streaming content has no thinking tags', () => { const context = withTaggedThinkingStreamParser(); - const firstChunk = converter.convertOpenAIChunkToGemini( + const firstChunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-glm-reasoning-only-no-content-1', @@ -5796,7 +5796,7 @@ describe('OpenAIContentConverter', () => { } as unknown as OpenAI.Chat.ChatCompletionChunk, context, ); - const finalChunk = converter.convertOpenAIChunkToGemini( + const finalChunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-glm-reasoning-only-no-content-2', @@ -5823,7 +5823,7 @@ describe('OpenAIContentConverter', () => { it('should flush unclosed streaming thinking content on finish', () => { const context = withTaggedThinkingStreamParser(); - const chunk = converter.convertOpenAIChunkToGemini( + const chunk = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-minimax-unclosed', @@ -5848,9 +5848,9 @@ describe('OpenAIContentConverter', () => { }); }); - describe('convertGeminiToolsToOpenAI', () => { + describe('convertLlmToolsToOpenAI', () => { it('should convert Gemini tools with parameters field', async () => { - const geminiTools = [ + const llmTools = [ { functionDeclarations: [ { @@ -5868,7 +5868,7 @@ describe('OpenAIContentConverter', () => { }, ] as Tool[]; - const result = await converter.convertGeminiToolsToOpenAI(geminiTools); + const result = await converter.convertLlmToolsToOpenAI(llmTools); expect(result).toHaveLength(1); expect(result[0]).toEqual({ @@ -5916,7 +5916,7 @@ describe('OpenAIContentConverter', () => { }, ] as Tool[]; - const result = await converter.convertGeminiToolsToOpenAI(agentLikeTools); + const result = await converter.convertLlmToolsToOpenAI(agentLikeTools); const params = result[0]!.function.parameters as Record; expect(params['additionalProperties']).toBeUndefined(); expect(params['$schema']).toBeUndefined(); @@ -5942,7 +5942,7 @@ describe('OpenAIContentConverter', () => { }, ] as Tool[]; - const result = await converter.convertGeminiToolsToOpenAI(strictTools); + const result = await converter.convertLlmToolsToOpenAI(strictTools); const params = result[0]!.function.parameters as Record; expect(params['additionalProperties']).toBe(false); }); @@ -5967,7 +5967,7 @@ describe('OpenAIContentConverter', () => { }, ] as Tool[]; - const result = await converter.convertGeminiToolsToOpenAI(mcpTools); + const result = await converter.convertLlmToolsToOpenAI(mcpTools); expect(result).toHaveLength(1); expect(result[0]).toEqual({ @@ -6004,14 +6004,14 @@ describe('OpenAIContentConverter', () => { }, ] as CallableTool[]; - const result = await converter.convertGeminiToolsToOpenAI(callableTools); + const result = await converter.convertLlmToolsToOpenAI(callableTools); expect(result).toHaveLength(1); expect(result[0].function.name).toBe('dynamic_tool'); }); it('should preserve functions without description and skip functions without name', async () => { - const geminiTools = [ + const llmTools = [ { functionDeclarations: [ { @@ -6030,7 +6030,7 @@ describe('OpenAIContentConverter', () => { }, ] as Tool[]; - const result = await converter.convertGeminiToolsToOpenAI(geminiTools); + const result = await converter.convertLlmToolsToOpenAI(llmTools); expect(result).toHaveLength(2); expect(result[0].function.name).toBe('valid_tool'); @@ -6042,13 +6042,13 @@ describe('OpenAIContentConverter', () => { it('should handle tools without functionDeclarations', async () => { const emptyTools: Tool[] = [{} as Tool, { functionDeclarations: [] }]; - const result = await converter.convertGeminiToolsToOpenAI(emptyTools); + const result = await converter.convertLlmToolsToOpenAI(emptyTools); expect(result).toHaveLength(0); }); it('should handle functions without parameters', async () => { - const geminiTools: Tool[] = [ + const llmTools: Tool[] = [ { functionDeclarations: [ { @@ -6059,7 +6059,7 @@ describe('OpenAIContentConverter', () => { }, ]; - const result = await converter.convertGeminiToolsToOpenAI(geminiTools); + const result = await converter.convertLlmToolsToOpenAI(llmTools); expect(result).toHaveLength(1); expect(result[0].function.parameters).toBeUndefined(); @@ -6082,7 +6082,7 @@ describe('OpenAIContentConverter', () => { } as Tool, ]; - const result = await converter.convertGeminiToolsToOpenAI(mcpTools); + const result = await converter.convertLlmToolsToOpenAI(mcpTools); // Verify the result is a copy, not the same reference expect(result[0].function.parameters).not.toBe(originalSchema); @@ -6090,7 +6090,7 @@ describe('OpenAIContentConverter', () => { }); }); - describe('convertGeminiToolParametersToOpenAI', () => { + describe('convertLlmToolParametersToOpenAI', () => { it('should convert type names to lowercase', () => { const params = { type: 'OBJECT', @@ -6101,7 +6101,7 @@ describe('OpenAIContentConverter', () => { }, }; - const result = converter.convertGeminiToolParametersToOpenAI(params); + const result = converter.convertLlmToolParametersToOpenAI(params); expect(result).toEqual({ type: 'object', @@ -6130,7 +6130,7 @@ describe('OpenAIContentConverter', () => { }, }; - const result = converter.convertGeminiToolParametersToOpenAI(params); + const result = converter.convertLlmToolParametersToOpenAI(params); const properties = result?.['properties'] as Record; expect(properties?.['maximum']).toEqual({ @@ -6155,7 +6155,7 @@ describe('OpenAIContentConverter', () => { }, }; - const result = converter.convertGeminiToolParametersToOpenAI(params); + const result = converter.convertLlmToolParametersToOpenAI(params); const properties = result?.['properties'] as Record; expect(properties?.['value']).toEqual({ @@ -6183,7 +6183,7 @@ describe('OpenAIContentConverter', () => { }, }; - const result = converter.convertGeminiToolParametersToOpenAI(params); + const result = converter.convertLlmToolParametersToOpenAI(params); const properties = result?.['properties'] as Record; expect(properties?.['text']).toEqual({ @@ -6215,7 +6215,7 @@ describe('OpenAIContentConverter', () => { }, }; - const result = converter.convertGeminiToolParametersToOpenAI(params); + const result = converter.convertLlmToolParametersToOpenAI(params); const properties = result?.['properties'] as Record; expect(properties?.['text']).toEqual({ @@ -6246,7 +6246,7 @@ describe('OpenAIContentConverter', () => { }, }; - const result = converter.convertGeminiToolParametersToOpenAI(params); + const result = converter.convertLlmToolParametersToOpenAI(params); const properties = result?.['properties'] as Record; const nested = properties?.['nested'] as Record; const nestedProperties = nested?.['properties'] as Record< @@ -6268,7 +6268,7 @@ describe('OpenAIContentConverter', () => { }, }; - const result = converter.convertGeminiToolParametersToOpenAI(params); + const result = converter.convertLlmToolParametersToOpenAI(params); expect(result).toEqual({ type: 'array', @@ -6280,12 +6280,12 @@ describe('OpenAIContentConverter', () => { it('should return undefined for null or non-object input', () => { expect( - converter.convertGeminiToolParametersToOpenAI( + converter.convertLlmToolParametersToOpenAI( null as unknown as Record, ), ).toBeNull(); expect( - converter.convertGeminiToolParametersToOpenAI( + converter.convertLlmToolParametersToOpenAI( undefined as unknown as Record, ), ).toBeUndefined(); @@ -6300,7 +6300,7 @@ describe('OpenAIContentConverter', () => { }; const originalCopy = JSON.parse(JSON.stringify(original)); - converter.convertGeminiToolParametersToOpenAI(original); + converter.convertLlmToolParametersToOpenAI(original); expect(original).toEqual(originalCopy); }); @@ -6328,7 +6328,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -6357,7 +6357,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -6386,7 +6386,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -6451,7 +6451,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, { @@ -6486,7 +6486,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -6512,7 +6512,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -6540,7 +6540,7 @@ describe('OpenAIContentConverter', () => { ], }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -6623,7 +6623,7 @@ describe('MCP tool result end-to-end through OpenAI converter (issue #1520)', () model: 'models/test', contents, }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -6696,7 +6696,7 @@ describe('MCP tool result end-to-end through OpenAI converter (issue #1520)', () model: 'models/test', contents, }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -6761,7 +6761,7 @@ describe('MCP tool result end-to-end through OpenAI converter (issue #1520)', () model: 'models/test', contents, }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -6829,7 +6829,7 @@ describe('MCP tool result end-to-end through OpenAI converter (issue #1520)', () model: 'models/test', contents, }; - const messages = converter.convertGeminiRequestToOpenAI( + const messages = converter.convertLlmRequestToOpenAI( request, requestContext, ); @@ -6897,7 +6897,7 @@ describe('Truncated tool call detection in streaming', () => { // Feed argument chunks (no finish_reason yet) for (const tc of toolCallChunks) { - conv.convertOpenAIChunkToGemini( + conv.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-stream', @@ -6929,7 +6929,7 @@ describe('Truncated tool call detection in streaming', () => { } // Final chunk with finish_reason - return conv.convertOpenAIChunkToGemini( + return conv.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-final', @@ -6950,7 +6950,7 @@ describe('Truncated tool call detection in streaming', () => { it('emits tool preparation metadata before the complete function call', () => { const context = createStreamingRequestContext(); - const opener = converter.convertOpenAIChunkToGemini( + const opener = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-open', @@ -6976,7 +6976,7 @@ describe('Truncated tool call detection in streaming', () => { } as unknown as OpenAI.Chat.ChatCompletionChunk, context, ); - const args = converter.convertOpenAIChunkToGemini( + const args = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-args', @@ -7001,7 +7001,7 @@ describe('Truncated tool call detection in streaming', () => { } as unknown as OpenAI.Chat.ChatCompletionChunk, context, ); - const finish = converter.convertOpenAIChunkToGemini( + const finish = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-finish', @@ -7056,8 +7056,8 @@ describe('Truncated tool call detection in streaming', () => { ], } as unknown as OpenAI.Chat.ChatCompletionChunk; - const first = converter.convertOpenAIChunkToGemini(opener, context); - const replay = converter.convertOpenAIChunkToGemini(opener, context); + const first = converter.convertOpenAIChunkToLlm(opener, context); + const replay = converter.convertOpenAIChunkToLlm(opener, context); expect(getToolCallPreparations(first)).toEqual([ { callId: 'call-1', toolName: 'read_file' }, @@ -7068,7 +7068,7 @@ describe('Truncated tool call detection in streaming', () => { it('emits preparation after split identity deltas using the remapped parser index', () => { const context = createStreamingRequestContext(); - const firstCall = converter.convertOpenAIChunkToGemini( + const firstCall = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-first-call', @@ -7097,7 +7097,7 @@ describe('Truncated tool call detection in streaming', () => { } as unknown as OpenAI.Chat.ChatCompletionChunk, context, ); - const secondCallId = converter.convertOpenAIChunkToGemini( + const secondCallId = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-second-id', @@ -7116,7 +7116,7 @@ describe('Truncated tool call detection in streaming', () => { } as unknown as OpenAI.Chat.ChatCompletionChunk, context, ); - const secondCallName = converter.convertOpenAIChunkToGemini( + const secondCallName = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-second-name', @@ -7144,7 +7144,7 @@ describe('Truncated tool call detection in streaming', () => { } as unknown as OpenAI.Chat.ChatCompletionChunk, context, ); - const thirdCallName = converter.convertOpenAIChunkToGemini( + const thirdCallName = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-third-name', @@ -7172,7 +7172,7 @@ describe('Truncated tool call detection in streaming', () => { } as unknown as OpenAI.Chat.ChatCompletionChunk, context, ); - const thirdCallArguments = converter.convertOpenAIChunkToGemini( + const thirdCallArguments = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-third-arguments', @@ -7197,7 +7197,7 @@ describe('Truncated tool call detection in streaming', () => { } as unknown as OpenAI.Chat.ChatCompletionChunk, context, ); - const thirdCallId = converter.convertOpenAIChunkToGemini( + const thirdCallId = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-third-id', @@ -7216,7 +7216,7 @@ describe('Truncated tool call detection in streaming', () => { } as unknown as OpenAI.Chat.ChatCompletionChunk, context, ); - const finish = converter.convertOpenAIChunkToGemini( + const finish = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-finish', @@ -7278,7 +7278,7 @@ describe('Truncated tool call detection in streaming', () => { }, }, ])('does not emit tool preparation metadata when $label', ({ toolCall }) => { - const response = converter.convertOpenAIChunkToGemini( + const response = converter.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'chunk-open', @@ -7400,7 +7400,7 @@ describe('Truncated tool call detection in streaming', () => { const ctx = createStreamingRequestContext(); // Chunk 1: start of JSON with tool metadata - conv.convertOpenAIChunkToGemini( + conv.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'c1', @@ -7428,7 +7428,7 @@ describe('Truncated tool call detection in streaming', () => { ); // Chunk 2: more arguments - conv.convertOpenAIChunkToGemini( + conv.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'c2', @@ -7454,7 +7454,7 @@ describe('Truncated tool call detection in streaming', () => { ); // Final chunk: finish_reason "stop" but JSON is still incomplete - const result = conv.convertOpenAIChunkToGemini( + const result = conv.convertOpenAIChunkToLlm( { object: 'chat.completion.chunk', id: 'c3', @@ -7476,7 +7476,7 @@ describe('Truncated tool call detection in streaming', () => { }); }); -describe('mapGeminiFinishReasonToOpenAI', () => { +describe('mapLlmFinishReasonToOpenAI', () => { it.each([ [FinishReason.STOP, 'stop'], [FinishReason.MAX_TOKENS, 'length'], @@ -7491,10 +7491,10 @@ describe('mapGeminiFinishReasonToOpenAI', () => { [FinishReason.IMAGE_OTHER, 'content_filter'], [FinishReason.NO_IMAGE, 'stop'], [undefined, 'stop'], - ])('maps %s to %s', (geminiReason, expected) => { - const response = OpenAIContentConverter.convertGeminiResponseToOpenAI( + ])('maps %s to %s', (llmReason, expected) => { + const response = OpenAIContentConverter.convertLlmResponseToOpenAI( { - candidates: [{ finishReason: geminiReason, content: { parts: [] } }], + candidates: [{ finishReason: llmReason, content: { parts: [] } }], } as unknown as GenerateContentResponse, { model: 'test-model', @@ -7552,7 +7552,7 @@ describe('modality filtering', () => { displayName: 'screenshot.png', } as unknown as Part, ]); - const messages = conv.convertGeminiRequestToOpenAI( + const messages = conv.convertLlmRequestToOpenAI( request, makeRequestContext('deepseek-chat', {}), ); @@ -7570,7 +7570,7 @@ describe('modality filtering', () => { inlineData: { mimeType: 'image/bmp', data: 'abc123' }, } as unknown as Part, ]); - const messages = conv.convertGeminiRequestToOpenAI( + const messages = conv.convertLlmRequestToOpenAI( request, makeRequestContext('gpt-4o', { image: true }), ); @@ -7594,7 +7594,7 @@ describe('modality filtering', () => { }, } as unknown as Part, ]); - const messages = conv.convertGeminiRequestToOpenAI( + const messages = conv.convertLlmRequestToOpenAI( request, makeRequestContext('test-model', { image: true }), ); @@ -7616,7 +7616,7 @@ describe('modality filtering', () => { }, } as unknown as Part, ]); - const messages = conv.convertGeminiRequestToOpenAI( + const messages = conv.convertLlmRequestToOpenAI( request, makeRequestContext('claude-sonnet', { image: true, pdf: true }), ); @@ -7632,7 +7632,7 @@ describe('modality filtering', () => { inlineData: { mimeType: 'video/mp4', data: 'vid-data' }, } as unknown as Part, ]); - const messages = conv.convertGeminiRequestToOpenAI( + const messages = conv.convertLlmRequestToOpenAI( request, makeRequestContext('test-model', {}), ); @@ -7649,7 +7649,7 @@ describe('modality filtering', () => { inlineData: { mimeType: 'audio/wav', data: 'audio-data' }, } as unknown as Part, ]); - const messages = conv.convertGeminiRequestToOpenAI( + const messages = conv.convertLlmRequestToOpenAI( request, makeRequestContext('test-model', {}), ); @@ -7670,7 +7670,7 @@ describe('modality filtering', () => { inlineData: { mimeType: 'video/mp4', data: 'vid-data' }, } as unknown as Part, ]); - const messages = conv.convertGeminiRequestToOpenAI( + const messages = conv.convertLlmRequestToOpenAI( request, makeRequestContext('gpt-4o', { image: true }), ); @@ -7690,7 +7690,7 @@ describe('modality filtering', () => { inlineData: { mimeType: 'image/png', data: 'img-data' }, } as unknown as Part, ]); - const messages = conv.convertGeminiRequestToOpenAI( + const messages = conv.convertLlmRequestToOpenAI( request, makeRequestContext('unknown-model', {}), ); diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index 45b9be180f8..225d3d42209 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -251,7 +251,7 @@ type OpenAIContentPart = /** * Convert Gemini tool parameters to OpenAI JSON Schema format. */ -export function convertGeminiToolParametersToOpenAI( +export function convertLlmToolParametersToOpenAI( parameters: Record, ): Record | undefined { if (!parameters || typeof parameters !== 'object') { @@ -331,13 +331,13 @@ export function convertGeminiToolParametersToOpenAI( * Handles both Gemini tools (using 'parameters' field) and MCP tools * (using 'parametersJsonSchema' field). */ -export async function convertGeminiToolsToOpenAI( - geminiTools: ToolListUnion, +export async function convertLlmToolsToOpenAI( + llmTools: ToolListUnion, schemaCompliance: SchemaComplianceMode = 'auto', ): Promise { const openAITools: OpenAI.Chat.ChatCompletionTool[] = []; - for (const tool of geminiTools) { + for (const tool of llmTools) { let actualTool: Tool; // Handle CallableTool vs Tool @@ -364,7 +364,7 @@ export async function convertGeminiToolsToOpenAI( parameters = paramsCopy; } else if (func.parameters) { // Gemini tool format - convert parameters to OpenAI format - parameters = convertGeminiToolParametersToOpenAI( + parameters = convertLlmToolParametersToOpenAI( func.parameters as Record, ); } @@ -399,7 +399,7 @@ export async function convertGeminiToolsToOpenAI( /** * Convert Gemini request to OpenAI message format. */ -export function convertGeminiRequestToOpenAI( +export function convertLlmRequestToOpenAI( request: GenerateContentParameters, requestContext: RequestContext, options: { cleanOrphanToolCalls: boolean } = { cleanOrphanToolCalls: true }, @@ -424,7 +424,7 @@ export function convertGeminiRequestToOpenAI( /** * Convert Gemini response to OpenAI completion format (for logging). */ -export function convertGeminiResponseToOpenAI( +export function convertLlmResponseToOpenAI( response: GenerateContentResponse, requestContext: RequestContext, ): OpenAI.Chat.ChatCompletion { @@ -474,7 +474,7 @@ export function convertGeminiResponseToOpenAI( message.tool_calls = toolCalls; } - const finishReason = mapGeminiFinishReasonToOpenAI(candidate?.finishReason); + const finishReason = mapLlmFinishReasonToOpenAI(candidate?.finishReason); const usageMetadata = response.usageMetadata; const usage: OpenAI.CompletionUsage = { @@ -1210,7 +1210,7 @@ function throwProtocolTagLeak(requestContext: RequestContext): never { /** * Convert OpenAI response to Gemini format. */ -export function convertOpenAIResponseToGemini( +export function convertOpenAIResponseToLlm( openaiResponse: OpenAI.Chat.ChatCompletion, requestContext: RequestContext, ): GenerateContentResponse { @@ -1262,7 +1262,7 @@ export function convertOpenAIResponseToGemini( parts, role: 'model' as const, }, - finishReason: mapOpenAIFinishReasonToGemini( + finishReason: mapOpenAIFinishReasonToLlm( choice.finish_reason || 'stop', ), index: 0, @@ -1309,7 +1309,7 @@ export function convertOpenAIResponseToGemini( : estimatedThinkingTokens; if (thinkingTokens > 0) { debugLogger.debug( - `convertOpenAIResponseToGemini: reasoning_tokens absent; estimated ${thinkingTokens} from text`, + `convertOpenAIResponseToLlm: reasoning_tokens absent; estimated ${thinkingTokens} from text`, ); } } @@ -1344,7 +1344,7 @@ export function convertOpenAIResponseToGemini( * same instance for every chunk of that stream. Concurrent streams MUST use * distinct parsers or their tool-call buffers will interleave (issue #3516). */ -export function convertOpenAIChunkToGemini( +export function convertOpenAIChunkToLlm( chunk: OpenAI.Chat.ChatCompletionChunk, requestContext: RequestContext, ): GenerateContentResponse { @@ -1354,7 +1354,7 @@ export function convertOpenAIChunkToGemini( const toolCallParser = requestContext.toolCallParser; if (!toolCallParser) { throw new Error( - 'convertOpenAIChunkToGemini requires requestContext.toolCallParser — attach a fresh StreamingToolCallParser at stream start.', + 'convertOpenAIChunkToLlm requires requestContext.toolCallParser — attach a fresh StreamingToolCallParser at stream start.', ); } @@ -1395,11 +1395,11 @@ export function convertOpenAIChunkToGemini( requestContext.hasTaggedThinkingThought = true; requestContext.pendingReasoningText = undefined; debugLogger.debug( - 'convertOpenAIChunkToGemini: tagged thinking content emitted a thought; dropping buffered reasoning', + 'convertOpenAIChunkToLlm: tagged thinking content emitted a thought; dropping buffered reasoning', ); if (requestContext.pendingContentParts?.length) { debugLogger.debug( - `convertOpenAIChunkToGemini: flushing ${requestContext.pendingContentParts.length} buffered content part(s) before tagged content`, + `convertOpenAIChunkToLlm: flushing ${requestContext.pendingContentParts.length} buffered content part(s) before tagged content`, ); parts.push(...requestContext.pendingContentParts); requestContext.pendingContentParts = undefined; @@ -1441,7 +1441,7 @@ export function convertOpenAIChunkToGemini( requestContext.pendingReasoningText = (requestContext.pendingReasoningText ?? '') + normalizedReasoningText; debugLogger.debug( - `convertOpenAIChunkToGemini: buffered reasoning text (${requestContext.pendingReasoningText.length} chars) for tagged stream`, + `convertOpenAIChunkToLlm: buffered reasoning text (${requestContext.pendingReasoningText.length} chars) for tagged stream`, ); } } @@ -1457,7 +1457,7 @@ export function convertOpenAIChunkToGemini( ...contentParts, ]; debugLogger.debug( - `convertOpenAIChunkToGemini: buffered ${contentParts.length} content part(s) behind pending reasoning`, + `convertOpenAIChunkToLlm: buffered ${contentParts.length} content part(s) behind pending reasoning`, ); contentParts = []; } @@ -1469,7 +1469,7 @@ export function convertOpenAIChunkToGemini( requestContext.pendingReasoningText ) { debugLogger.debug( - 'convertOpenAIChunkToGemini: flushing buffered reasoning for tagged stream with no tagged thought', + 'convertOpenAIChunkToLlm: flushing buffered reasoning for tagged stream with no tagged thought', ); parts.push( createOpenAIReasoningThoughtPart(requestContext.pendingReasoningText), @@ -1478,7 +1478,7 @@ export function convertOpenAIChunkToGemini( } if (choice.finish_reason && requestContext.pendingContentParts?.length) { debugLogger.debug( - `convertOpenAIChunkToGemini: flushing ${requestContext.pendingContentParts.length} buffered content part(s) on stream finish`, + `convertOpenAIChunkToLlm: flushing ${requestContext.pendingContentParts.length} buffered content part(s) on stream finish`, ); parts.push(...requestContext.pendingContentParts); requestContext.pendingContentParts = undefined; @@ -1757,7 +1757,7 @@ export function convertOpenAIChunkToGemini( safetyRatings: [], }; if (effectiveFinishReason) { - candidate.finishReason = mapOpenAIFinishReasonToGemini( + candidate.finishReason = mapOpenAIFinishReasonToLlm( effectiveFinishReason, ); } @@ -1794,7 +1794,7 @@ export function convertOpenAIChunkToGemini( : estimatedThinkingTokens); if (providerReasoningTokens == null && estimatedThinkingTokens > 0) { debugLogger.debug( - `convertOpenAIChunkToGemini: reasoning_tokens absent; estimated ${thinkingTokens} from streamed text`, + `convertOpenAIChunkToLlm: reasoning_tokens absent; estimated ${thinkingTokens} from streamed text`, ); } // Support both formats: prompt_tokens_details.cached_tokens (OpenAI standard) @@ -1834,9 +1834,7 @@ export function convertOpenAIChunkToGemini( return response; } -function mapOpenAIFinishReasonToGemini( - openaiReason: string | null, -): FinishReason { +function mapOpenAIFinishReasonToLlm(openaiReason: string | null): FinishReason { if (typeof openaiReason !== 'string') { return FinishReason.FINISH_REASON_UNSPECIFIED; } @@ -1854,14 +1852,14 @@ function mapOpenAIFinishReasonToGemini( ); } -function mapGeminiFinishReasonToOpenAI( - geminiReason?: FinishReason, +function mapLlmFinishReasonToOpenAI( + llmReason?: FinishReason, ): 'stop' | 'length' | 'tool_calls' | 'content_filter' | 'function_call' { - if (!geminiReason) { + if (!llmReason) { return 'stop'; } - switch (geminiReason) { + switch (llmReason) { case FinishReason.STOP: return 'stop'; case FinishReason.MAX_TOKENS: @@ -2188,10 +2186,10 @@ function mergeConsecutiveAssistantMessages( } export const OpenAIContentConverter = { - convertGeminiToolParametersToOpenAI, - convertGeminiToolsToOpenAI, - convertGeminiRequestToOpenAI, - convertGeminiResponseToOpenAI, - convertOpenAIResponseToGemini, - convertOpenAIChunkToGemini, + convertLlmToolParametersToOpenAI, + convertLlmToolsToOpenAI, + convertLlmRequestToOpenAI, + convertLlmResponseToOpenAI, + convertOpenAIResponseToLlm, + convertOpenAIChunkToLlm, }; diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts index 732be0c8adf..e03d94d5c54 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts @@ -53,10 +53,10 @@ const mockReportOpenAiChunk = vi.hoisted(() => vi.fn()); vi.mock('./converter.js', () => ({ OpenAIContentConverter: { - convertGeminiRequestToOpenAI: vi.fn(), - convertOpenAIResponseToGemini: vi.fn(), - convertOpenAIChunkToGemini: vi.fn(), - convertGeminiToolsToOpenAI: vi.fn(), + convertLlmRequestToOpenAI: vi.fn(), + convertOpenAIResponseToLlm: vi.fn(), + convertOpenAIChunkToLlm: vi.fn(), + convertLlmToolsToOpenAI: vi.fn(), }, })); vi.mock('openai'); @@ -164,13 +164,13 @@ describe('ContentGenerationPipeline', () => { model: 'test-model', usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, } as OpenAI.Chat.ChatCompletion; - const mockGeminiResponse = new GenerateContentResponse(); + const mockLlmResponse = new GenerateContentResponse(); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( mockMessages, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( - mockGeminiResponse, + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( + mockLlmResponse, ); (mockClient.chat.completions.create as Mock).mockResolvedValue( mockOpenAIResponse, @@ -182,8 +182,8 @@ describe('ContentGenerationPipeline', () => { const result = await pipeline.execute(request, userPromptId); // Assert - expect(result).toBe(mockGeminiResponse); - expect(mockConverter.convertGeminiRequestToOpenAI).toHaveBeenCalledWith( + expect(result).toBe(mockLlmResponse); + expect(mockConverter.convertLlmRequestToOpenAI).toHaveBeenCalledWith( request, expect.objectContaining({ model: 'test-model', @@ -210,7 +210,7 @@ describe('ContentGenerationPipeline', () => { telemetryAttempt, mockOpenAIResponse, ); - expect(mockConverter.convertOpenAIResponseToGemini).toHaveBeenCalledWith( + expect(mockConverter.convertOpenAIResponseToLlm).toHaveBeenCalledWith( mockOpenAIResponse, expect.objectContaining({ model: 'test-model', @@ -238,13 +238,13 @@ describe('ContentGenerationPipeline', () => { created: Date.now(), model: 'override-model', } as OpenAI.Chat.ChatCompletion; - const mockGeminiResponse = new GenerateContentResponse(); + const mockLlmResponse = new GenerateContentResponse(); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( mockMessages, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( - mockGeminiResponse, + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( + mockLlmResponse, ); (mockClient.chat.completions.create as Mock).mockResolvedValue( mockOpenAIResponse, @@ -254,8 +254,8 @@ describe('ContentGenerationPipeline', () => { const result = await pipeline.execute(request, userPromptId); // Assert — request.model takes precedence over contentGeneratorConfig.model - expect(result).toBe(mockGeminiResponse); - expect(mockConverter.convertGeminiRequestToOpenAI).toHaveBeenCalledWith( + expect(result).toBe(mockLlmResponse); + expect(mockConverter.convertLlmRequestToOpenAI).toHaveBeenCalledWith( request, expect.objectContaining({ model: 'override-model', @@ -286,16 +286,16 @@ describe('ContentGenerationPipeline', () => { created: Date.now(), model: 'test-model', } as OpenAI.Chat.ChatCompletion; - const mockGeminiResponse = new GenerateContentResponse(); + const mockLlmResponse = new GenerateContentResponse(); mockProvider.getRequestContextOverrides = vi.fn().mockReturnValue({ splitToolMedia: true, }); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( mockMessages, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( - mockGeminiResponse, + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( + mockLlmResponse, ); (mockClient.chat.completions.create as Mock).mockResolvedValue( mockOpenAIResponse, @@ -303,7 +303,7 @@ describe('ContentGenerationPipeline', () => { await pipeline.execute(request, userPromptId); - expect(mockConverter.convertGeminiRequestToOpenAI).toHaveBeenCalledWith( + expect(mockConverter.convertLlmRequestToOpenAI).toHaveBeenCalledWith( request, expect.objectContaining({ splitToolMedia: true, @@ -328,17 +328,17 @@ describe('ContentGenerationPipeline', () => { created: Date.now(), model: 'test-model', } as OpenAI.Chat.ChatCompletion; - const mockGeminiResponse = new GenerateContentResponse(); + const mockLlmResponse = new GenerateContentResponse(); mockContentGeneratorConfig.splitToolMedia = true; mockProvider.getRequestContextOverrides = vi.fn().mockReturnValue({ splitToolMedia: false, }); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( mockMessages, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( - mockGeminiResponse, + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( + mockLlmResponse, ); (mockClient.chat.completions.create as Mock).mockResolvedValue( mockOpenAIResponse, @@ -346,7 +346,7 @@ describe('ContentGenerationPipeline', () => { await pipeline.execute(request, userPromptId); - expect(mockConverter.convertGeminiRequestToOpenAI).toHaveBeenCalledWith( + expect(mockConverter.convertLlmRequestToOpenAI).toHaveBeenCalledWith( request, expect.objectContaining({ splitToolMedia: false, @@ -371,18 +371,18 @@ describe('ContentGenerationPipeline', () => { created: Date.now(), model: 'test-model', } as OpenAI.Chat.ChatCompletion; - const mockGeminiResponse = new GenerateContentResponse(); + const mockLlmResponse = new GenerateContentResponse(); // Neither the provider nor the content generator config sets // splitToolMedia — it must default to true so tool-returned images are // moved out of the spec-violating `role: "tool"` message (#4876). mockProvider.getRequestContextOverrides = vi.fn().mockReturnValue({}); mockContentGeneratorConfig.splitToolMedia = undefined; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( mockMessages, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( - mockGeminiResponse, + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( + mockLlmResponse, ); (mockClient.chat.completions.create as Mock).mockResolvedValue( mockOpenAIResponse, @@ -390,7 +390,7 @@ describe('ContentGenerationPipeline', () => { await pipeline.execute(request, userPromptId); - expect(mockConverter.convertGeminiRequestToOpenAI).toHaveBeenCalledWith( + expect(mockConverter.convertLlmRequestToOpenAI).toHaveBeenCalledWith( request, expect.objectContaining({ splitToolMedia: true, @@ -415,15 +415,15 @@ describe('ContentGenerationPipeline', () => { created: Date.now(), model: 'test-model', } as OpenAI.Chat.ChatCompletion; - const mockGeminiResponse = new GenerateContentResponse(); + const mockLlmResponse = new GenerateContentResponse(); mockProvider.getRequestContextOverrides = vi.fn().mockReturnValue({}); mockContentGeneratorConfig.toolResultContentFormat = 'string'; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( mockMessages, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( - mockGeminiResponse, + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( + mockLlmResponse, ); (mockClient.chat.completions.create as Mock).mockResolvedValue( mockOpenAIResponse, @@ -431,7 +431,7 @@ describe('ContentGenerationPipeline', () => { await pipeline.execute(request, userPromptId); - expect(mockConverter.convertGeminiRequestToOpenAI).toHaveBeenCalledWith( + expect(mockConverter.convertLlmRequestToOpenAI).toHaveBeenCalledWith( request, expect.objectContaining({ toolResultContentFormat: 'string', @@ -456,17 +456,17 @@ describe('ContentGenerationPipeline', () => { created: Date.now(), model: 'test-model', } as OpenAI.Chat.ChatCompletion; - const mockGeminiResponse = new GenerateContentResponse(); + const mockLlmResponse = new GenerateContentResponse(); mockContentGeneratorConfig.toolResultContentFormat = 'parts'; mockProvider.getRequestContextOverrides = vi.fn().mockReturnValue({ toolResultContentFormat: 'string', }); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( mockMessages, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( - mockGeminiResponse, + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( + mockLlmResponse, ); (mockClient.chat.completions.create as Mock).mockResolvedValue( mockOpenAIResponse, @@ -474,7 +474,7 @@ describe('ContentGenerationPipeline', () => { await pipeline.execute(request, userPromptId); - expect(mockConverter.convertGeminiRequestToOpenAI).toHaveBeenCalledWith( + expect(mockConverter.convertLlmRequestToOpenAI).toHaveBeenCalledWith( request, expect.objectContaining({ toolResultContentFormat: 'string', @@ -501,13 +501,13 @@ describe('ContentGenerationPipeline', () => { created: Date.now(), model: 'test-model', } as OpenAI.Chat.ChatCompletion; - const mockGeminiResponse = new GenerateContentResponse(); + const mockLlmResponse = new GenerateContentResponse(); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( mockMessages, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( - mockGeminiResponse, + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( + mockLlmResponse, ); (mockClient.chat.completions.create as Mock).mockResolvedValue( mockOpenAIResponse, @@ -517,8 +517,8 @@ describe('ContentGenerationPipeline', () => { const result = await pipeline.execute(request, userPromptId); // Assert — falls back to contentGeneratorConfig.model - expect(result).toBe(mockGeminiResponse); - expect(mockConverter.convertGeminiRequestToOpenAI).toHaveBeenCalledWith( + expect(result).toBe(mockLlmResponse); + expect(mockConverter.convertLlmRequestToOpenAI).toHaveBeenCalledWith( request, expect.objectContaining({ model: 'test-model', @@ -565,16 +565,16 @@ describe('ContentGenerationPipeline', () => { { message: { content: 'Hello response' }, finish_reason: 'stop' }, ], } as OpenAI.Chat.ChatCompletion; - const mockGeminiResponse = new GenerateContentResponse(); + const mockLlmResponse = new GenerateContentResponse(); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( mockMessages, ); - (mockConverter.convertGeminiToolsToOpenAI as Mock).mockResolvedValue( + (mockConverter.convertLlmToolsToOpenAI as Mock).mockResolvedValue( mockTools, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( - mockGeminiResponse, + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( + mockLlmResponse, ); (mockClient.chat.completions.create as Mock).mockResolvedValue( mockOpenAIResponse, @@ -584,14 +584,14 @@ describe('ContentGenerationPipeline', () => { const result = await pipeline.execute(request, userPromptId); // Assert - expect(result).toBe(mockGeminiResponse); - expect(mockConverter.convertGeminiRequestToOpenAI).toHaveBeenCalledWith( + expect(result).toBe(mockLlmResponse); + expect(mockConverter.convertLlmRequestToOpenAI).toHaveBeenCalledWith( request, expect.objectContaining({ model: 'test-model', }), ); - expect(mockConverter.convertGeminiToolsToOpenAI).toHaveBeenCalledWith( + expect(mockConverter.convertLlmToolsToOpenAI).toHaveBeenCalledWith( request.config!.tools, 'auto', ); @@ -621,13 +621,13 @@ describe('ContentGenerationPipeline', () => { id: 'response-id', choices: [{ message: { content: 'Response' }, finish_reason: 'stop' }], } as OpenAI.Chat.ChatCompletion; - const mockGeminiResponse = new GenerateContentResponse(); + const mockLlmResponse = new GenerateContentResponse(); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( mockMessages, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( - mockGeminiResponse, + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( + mockLlmResponse, ); (mockClient.chat.completions.create as Mock).mockResolvedValue( mockOpenAIResponse, @@ -637,7 +637,7 @@ describe('ContentGenerationPipeline', () => { await pipeline.execute(request, userPromptId); // Assert — tools should NOT be in the request - expect(mockConverter.convertGeminiToolsToOpenAI).not.toHaveBeenCalled(); + expect(mockConverter.convertLlmToolsToOpenAI).not.toHaveBeenCalled(); const apiCall = (mockClient.chat.completions.create as Mock).mock .calls[0][0]; expect(apiCall.tools).toBeUndefined(); @@ -686,13 +686,13 @@ describe('ContentGenerationPipeline', () => { }, ], } as OpenAI.Chat.ChatCompletion; - const mockGeminiResponse = new GenerateContentResponse(); + const mockLlmResponse = new GenerateContentResponse(); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( mockMessages, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( - mockGeminiResponse, + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( + mockLlmResponse, ); (mockClient.chat.completions.create as Mock).mockResolvedValue( mockOpenAIResponse, @@ -973,13 +973,13 @@ describe('ContentGenerationPipeline', () => { }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Summarize' }, ]); - (mockConverter.convertGeminiToolsToOpenAI as Mock).mockResolvedValue([ + (mockConverter.convertLlmToolsToOpenAI as Mock).mockResolvedValue([ { type: 'function', function: { name: 'respond_in_schema' } }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -1051,13 +1051,13 @@ describe('ContentGenerationPipeline', () => { }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Summarize' }, ]); - (mockConverter.convertGeminiToolsToOpenAI as Mock).mockResolvedValue([ + (mockConverter.convertLlmToolsToOpenAI as Mock).mockResolvedValue([ { type: 'function', function: { name: 'respond_in_schema' } }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -1108,10 +1108,10 @@ describe('ContentGenerationPipeline', () => { config: { thinkingConfig: { includeThoughts: true } }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Summarize' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -1159,10 +1159,10 @@ describe('ContentGenerationPipeline', () => { realProvider.buildRequest(req, 'prompt-id'), ); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Summarize' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -1237,13 +1237,13 @@ describe('ContentGenerationPipeline', () => { }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Summarize' }, ]); - (mockConverter.convertGeminiToolsToOpenAI as Mock).mockResolvedValue([ + (mockConverter.convertLlmToolsToOpenAI as Mock).mockResolvedValue([ { type: 'function', function: { name: 'respond_in_schema' } }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -1278,13 +1278,13 @@ describe('ContentGenerationPipeline', () => { ...req, enable_thinking: true, })); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'What is 2+2?' }, ]); - (mockConverter.convertGeminiToolsToOpenAI as Mock).mockResolvedValue([ + (mockConverter.convertLlmToolsToOpenAI as Mock).mockResolvedValue([ { type: 'function', function: { name: 'respond_in_schema' } }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); @@ -1392,10 +1392,10 @@ describe('ContentGenerationPipeline', () => { contentGeneratorConfig: mockContentGeneratorConfig, }); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'What is 2+2?' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); @@ -1455,7 +1455,7 @@ describe('ContentGenerationPipeline', () => { ...req, enable_thinking: true, })); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'What is 2+2?' }, ]); @@ -1492,7 +1492,7 @@ describe('ContentGenerationPipeline', () => { ...req, enable_thinking: true, })); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'What is 2+2?' }, ]); @@ -1544,7 +1544,7 @@ describe('ContentGenerationPipeline', () => { }; pipeline = new ContentGenerationPipeline(mockConfig); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Hello' }, ]); const error = Object.assign(new Error(message), { @@ -1586,13 +1586,13 @@ describe('ContentGenerationPipeline', () => { id: 'response-id', choices: [{ message: { content: 'run tests' }, finish_reason: 'stop' }], } as OpenAI.Chat.ChatCompletion; - const mockGeminiResponse = new GenerateContentResponse(); + const mockLlmResponse = new GenerateContentResponse(); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( mockMessages, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( - mockGeminiResponse, + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( + mockLlmResponse, ); (mockClient.chat.completions.create as Mock).mockResolvedValue( mockOpenAIResponse, @@ -1624,10 +1624,10 @@ describe('ContentGenerationPipeline', () => { config: { thinkingConfig: { includeThoughts: false } }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Classify action' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -1662,13 +1662,13 @@ describe('ContentGenerationPipeline', () => { id: 'response-id', choices: [{ message: { content: 'Hi there' }, finish_reason: 'stop' }], } as OpenAI.Chat.ChatCompletion; - const mockGeminiResponse = new GenerateContentResponse(); + const mockLlmResponse = new GenerateContentResponse(); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( mockMessages, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( - mockGeminiResponse, + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( + mockLlmResponse, ); (mockClient.chat.completions.create as Mock).mockResolvedValue( mockOpenAIResponse, @@ -1704,10 +1704,10 @@ describe('ContentGenerationPipeline', () => { config: { thinkingConfig: { includeThoughts: false } }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Suggest next' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -1742,10 +1742,10 @@ describe('ContentGenerationPipeline', () => { contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Hello' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -1780,10 +1780,10 @@ describe('ContentGenerationPipeline', () => { config: { thinkingConfig: { includeThoughts: false } }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Suggest' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -1820,10 +1820,10 @@ describe('ContentGenerationPipeline', () => { config: { thinkingConfig: { includeThoughts: false } }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Suggest' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -1865,10 +1865,10 @@ describe('ContentGenerationPipeline', () => { config: { thinkingConfig: { includeThoughts: false } }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Summarize' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -1903,10 +1903,10 @@ describe('ContentGenerationPipeline', () => { contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Hello' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -1946,10 +1946,10 @@ describe('ContentGenerationPipeline', () => { config: { thinkingConfig: { includeThoughts: false } }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Hi' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -1987,10 +1987,10 @@ describe('ContentGenerationPipeline', () => { config: { thinkingConfig: { includeThoughts: false } }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Hi' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -2027,10 +2027,10 @@ describe('ContentGenerationPipeline', () => { config: { thinkingConfig: { includeThoughts: false } }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Suggest' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -2074,10 +2074,10 @@ describe('ContentGenerationPipeline', () => { config: { thinkingConfig: { includeThoughts: false } }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Suggest' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -2114,10 +2114,10 @@ describe('ContentGenerationPipeline', () => { config: { thinkingConfig: { includeThoughts: false } }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Suggest' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -2159,10 +2159,10 @@ describe('ContentGenerationPipeline', () => { config: { thinkingConfig: { includeThoughts: false } }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Suggest' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -2203,10 +2203,10 @@ describe('ContentGenerationPipeline', () => { config: { thinkingConfig: { includeThoughts: false } }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Summarize' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -2243,10 +2243,10 @@ describe('ContentGenerationPipeline', () => { config: { thinkingConfig: { includeThoughts: false } }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Summarize' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -2282,10 +2282,10 @@ describe('ContentGenerationPipeline', () => { config: { thinkingConfig: { includeThoughts: false } }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Summarize' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -2324,10 +2324,10 @@ describe('ContentGenerationPipeline', () => { config: { thinkingConfig: { includeThoughts: false } }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Summarize' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -2351,7 +2351,7 @@ describe('ContentGenerationPipeline', () => { const userPromptId = 'test-prompt-id'; const testError = new Error('API Error'); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); (mockClient.chat.completions.create as Mock).mockRejectedValue(testError); // Act & Assert @@ -2376,7 +2376,7 @@ describe('ContentGenerationPipeline', () => { 'connect ECONNREFUSED token@proxy.local:8080', ); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); (mockClient.chat.completions.create as Mock).mockRejectedValue(testError); await expect(pipeline.execute(request, userPromptId)).rejects.toThrow( @@ -2401,8 +2401,8 @@ describe('ContentGenerationPipeline', () => { config: { abortSignal: abortController.signal }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -2429,7 +2429,7 @@ describe('ContentGenerationPipeline', () => { }; let capturedSignal: AbortSignal | undefined; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); (mockClient.chat.completions.create as Mock).mockImplementation( (_req: unknown, opts: { signal: AbortSignal }) => { capturedSignal = opts.signal; @@ -2437,7 +2437,7 @@ describe('ContentGenerationPipeline', () => { return { choices: [{ message: { content: 'ok' } }] }; }, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); @@ -2465,7 +2465,7 @@ describe('ContentGenerationPipeline', () => { ...req, enable_thinking: true, })); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); const requiredThinkingError = Object.assign( new Error( @@ -2541,19 +2541,19 @@ describe('ContentGenerationPipeline', () => { }, }; - const mockGeminiResponse1 = new GenerateContentResponse(); - const mockGeminiResponse2 = new GenerateContentResponse(); - mockGeminiResponse1.candidates = [ + const mockLlmResponse1 = new GenerateContentResponse(); + const mockLlmResponse2 = new GenerateContentResponse(); + mockLlmResponse1.candidates = [ { content: { parts: [{ text: 'Hello' }], role: 'model' } }, ]; - mockGeminiResponse2.candidates = [ + mockLlmResponse2.candidates = [ { content: { parts: [{ text: ' response' }], role: 'model' } }, ]; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock) - .mockReturnValueOnce(mockGeminiResponse1) - .mockReturnValueOnce(mockGeminiResponse2); + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock) + .mockReturnValueOnce(mockLlmResponse1) + .mockReturnValueOnce(mockLlmResponse2); mockProvider.getResponseParsingOptions = vi.fn().mockReturnValue({ contentOnlyThinkingTagLeaks: true, }); @@ -2575,13 +2575,13 @@ describe('ContentGenerationPipeline', () => { // Assert expect(results).toHaveLength(2); - expect(results[0]).toBe(mockGeminiResponse1); - expect(results[1]).toBe(mockGeminiResponse2); + expect(results[0]).toBe(mockLlmResponse1); + expect(results[1]).toBe(mockLlmResponse2); const [, firstChunkContext] = ( - mockConverter.convertOpenAIChunkToGemini as Mock + mockConverter.convertOpenAIChunkToLlm as Mock ).mock.calls[0]; const [, secondChunkContext] = ( - mockConverter.convertOpenAIChunkToGemini as Mock + mockConverter.convertOpenAIChunkToLlm as Mock ).mock.calls[1]; expect(firstChunkContext).toEqual( expect.objectContaining({ @@ -2667,8 +2667,8 @@ describe('ContentGenerationPipeline', () => { { content: { parts: [{ text: 'Hello response' }], role: 'model' } }, ]; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock) + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock) .mockReturnValueOnce(mockEmptyCandidateResponse) .mockReturnValueOnce(mockEmptyChoicesResponse) .mockReturnValueOnce(mockMissingCandidatesResponse) @@ -2710,8 +2710,8 @@ describe('ContentGenerationPipeline', () => { { content: { parts: [], role: 'model' }, index: 0 }, ]; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock).mockImplementation( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock).mockImplementation( (_chunk, context) => { context.pendingThinkingTagCandidate = { text: '', @@ -2755,8 +2755,8 @@ describe('ContentGenerationPipeline', () => { { content: { parts: [], role: 'model' }, index: 0 }, ]; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock).mockImplementation( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock).mockImplementation( (_chunk, context) => { context.pendingThinkingTagCandidate = { text: ' ' }; return emptyResponse; @@ -2794,8 +2794,8 @@ describe('ContentGenerationPipeline', () => { { content: { parts: [], role: 'model' }, index: 0 }, ]; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock).mockImplementation( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock).mockImplementation( (_chunk, context) => { context.pendingThinkingTagCandidate = { text: ' ' }; context.pendingUntrustedResponseParts = [ @@ -2846,8 +2846,8 @@ describe('ContentGenerationPipeline', () => { }, ]; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock).mockImplementation( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock).mockImplementation( (_chunk, context) => { context.protocolTagSanitized = { tagName: 'think', @@ -2911,8 +2911,8 @@ describe('ContentGenerationPipeline', () => { { content: { parts: [], role: 'model' }, index: 0 }, ]; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock).mockImplementation( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock).mockImplementation( (chunk, context) => { if (chunk.id === 'finish-1') { context.protocolTagSanitized = { @@ -2988,8 +2988,8 @@ describe('ContentGenerationPipeline', () => { const usageResponse = new GenerateContentResponse(); usageResponse.usageMetadata = { totalTokenCount: 1 }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock).mockImplementation( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock).mockImplementation( (chunk, context) => { if (chunk.id === 'finish-1') return firstFinish; if (chunk.id === 'finish-2') { @@ -3051,8 +3051,8 @@ describe('ContentGenerationPipeline', () => { }, ]; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock).mockImplementation( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock).mockImplementation( (chunk, context) => { if (chunk.id === 'finish') { context.protocolTagSanitized = { @@ -3119,10 +3119,8 @@ describe('ContentGenerationPipeline', () => { }, ]; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( - [], - ); - (mockConverter.convertOpenAIChunkToGemini as Mock).mockImplementation( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock).mockImplementation( (_chunk, context) => { context.pendingThinkingTagCandidate = { text: '', @@ -3190,8 +3188,8 @@ describe('ContentGenerationPipeline', () => { { content: { parts: [], role: 'model' }, index: 0 }, ]; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock).mockImplementation( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock).mockImplementation( (_chunk, context) => { context.pendingThinkingTagCandidate = { text: '', @@ -3239,8 +3237,8 @@ describe('ContentGenerationPipeline', () => { { callId: 'call-1', toolName: 'read_file' }, ]); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock).mockReturnValue( preparationResponse, ); (mockClient.chat.completions.create as Mock).mockResolvedValue( @@ -3275,7 +3273,7 @@ describe('ContentGenerationPipeline', () => { }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); (mockClient.chat.completions.create as Mock).mockResolvedValue( mockStream, ); @@ -3316,7 +3314,7 @@ describe('ContentGenerationPipeline', () => { const userPromptId = 'test-prompt-id'; const testError = new Error('407 via http://user:pass@proxy.local'); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); (mockClient.chat.completions.create as Mock).mockRejectedValue(testError); await expect( @@ -3349,7 +3347,7 @@ describe('ContentGenerationPipeline', () => { }), }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); (mockClient.chat.completions.create as Mock).mockResolvedValue( mockStream, ); @@ -3400,7 +3398,7 @@ describe('ContentGenerationPipeline', () => { }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); (mockClient.chat.completions.create as Mock).mockResolvedValue( mockStream, ); @@ -3417,7 +3415,7 @@ describe('ContentGenerationPipeline', () => { }).rejects.toThrow(StreamContentError); expect(mockErrorHandler.handle).not.toHaveBeenCalled(); - expect(mockConverter.convertOpenAIChunkToGemini).not.toHaveBeenCalled(); + expect(mockConverter.convertOpenAIChunkToLlm).not.toHaveBeenCalled(); }); it('should redact proxy credentials from StreamContentError messages', async () => { @@ -3440,7 +3438,7 @@ describe('ContentGenerationPipeline', () => { }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); (mockClient.chat.completions.create as Mock).mockResolvedValue( mockStream, ); @@ -3491,7 +3489,7 @@ describe('ContentGenerationPipeline', () => { }), }); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); (mockClient.chat.completions.create as Mock).mockReturnValue( mockApiPromise, ); @@ -3545,7 +3543,7 @@ describe('ContentGenerationPipeline', () => { }), }); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); (mockClient.chat.completions.create as Mock).mockReturnValue( mockApiPromise, ); @@ -3599,7 +3597,7 @@ describe('ContentGenerationPipeline', () => { }), }); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); (mockClient.chat.completions.create as Mock).mockReturnValue( mockApiPromise, ); @@ -3652,7 +3650,7 @@ describe('ContentGenerationPipeline', () => { }), }); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); (mockClient.chat.completions.create as Mock).mockReturnValue( mockApiPromise, ); @@ -3674,8 +3672,8 @@ describe('ContentGenerationPipeline', () => { contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], }; - const mockGeminiResponse = new GenerateContentResponse(); - mockGeminiResponse.candidates = [ + const mockLlmResponse = new GenerateContentResponse(); + mockLlmResponse.candidates = [ { content: { parts: [{ text: 'Hello' }], role: 'model' }, finishReason: FinishReason.STOP, @@ -3713,9 +3711,9 @@ describe('ContentGenerationPipeline', () => { }), }); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock).mockReturnValue( - mockGeminiResponse, + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock).mockReturnValue( + mockLlmResponse, ); (mockClient.chat.completions.create as Mock).mockReturnValue( mockApiPromise, @@ -3735,8 +3733,8 @@ describe('ContentGenerationPipeline', () => { contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], }; - const mockGeminiResponse = new GenerateContentResponse(); - mockGeminiResponse.candidates = [ + const mockLlmResponse = new GenerateContentResponse(); + mockLlmResponse.candidates = [ { content: { parts: [{ text: 'Hello' }], role: 'model' }, finishReason: FinishReason.STOP, @@ -3757,9 +3755,9 @@ describe('ContentGenerationPipeline', () => { }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock).mockReturnValue( - mockGeminiResponse, + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock).mockReturnValue( + mockLlmResponse, ); // Regular mockResolvedValue — no withResponse method (mockClient.chat.completions.create as Mock).mockResolvedValue( @@ -3791,8 +3789,8 @@ describe('ContentGenerationPipeline', () => { }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue( @@ -3828,8 +3826,8 @@ describe('ContentGenerationPipeline', () => { }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue( @@ -3869,8 +3867,8 @@ describe('ContentGenerationPipeline', () => { }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue( @@ -3897,7 +3895,7 @@ describe('ContentGenerationPipeline', () => { }; let capturedSignal: AbortSignal | undefined; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); (mockClient.chat.completions.create as Mock).mockImplementation( (_req: unknown, opts: { signal: AbortSignal }) => { capturedSignal = opts.signal; @@ -3988,8 +3986,8 @@ describe('ContentGenerationPipeline', () => { cachedInputTokensReported: false, }); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock) + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock) .mockReturnValueOnce(mockContentResponse) .mockReturnValueOnce(mockFinishResponse) .mockReturnValueOnce(mockEmptyResponse) @@ -4090,8 +4088,8 @@ describe('ContentGenerationPipeline', () => { totalTokenCount: 30, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock) + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock) .mockReturnValueOnce(mockContentResponse) .mockReturnValueOnce(mockFinalResponse); (mockClient.chat.completions.create as Mock).mockResolvedValue( @@ -4199,8 +4197,8 @@ describe('ContentGenerationPipeline', () => { totalTokenCount: 30, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock) + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock) .mockReturnValueOnce(mockContentResponse) .mockReturnValueOnce(mockFinishResponseWithZeroUsage) .mockReturnValueOnce(mockUsageResponse); @@ -4287,8 +4285,8 @@ describe('ContentGenerationPipeline', () => { totalTokenCount: 30, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock) + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock) .mockReturnValueOnce(mockContentResponse) .mockReturnValueOnce(mockFinalResponse); (mockClient.chat.completions.create as Mock).mockResolvedValue( @@ -4411,8 +4409,8 @@ describe('ContentGenerationPipeline', () => { const mockTrailingResponse = new GenerateContentResponse(); mockTrailingResponse.candidates = []; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock) + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock) .mockReturnValueOnce(mockContentResponse) .mockReturnValueOnce(mockFinishResponse) .mockReturnValueOnce(mockUsageResponse) @@ -4534,10 +4532,10 @@ describe('ContentGenerationPipeline', () => { ] as OpenAI.Chat.ChatCompletionMessageParam[]; const mockOpenAIResponse = new GenerateContentResponse(); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( mockMessages, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( mockOpenAIResponse, ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -4575,10 +4573,10 @@ describe('ContentGenerationPipeline', () => { ] as OpenAI.Chat.ChatCompletionMessageParam[]; const mockOpenAIResponse = new GenerateContentResponse(); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( mockMessages, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( mockOpenAIResponse, ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -4623,10 +4621,10 @@ describe('ContentGenerationPipeline', () => { }), ); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( mockMessages, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( mockOpenAIResponse, ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -4676,10 +4674,10 @@ describe('ContentGenerationPipeline', () => { { role: 'user', content: 'Hello' }, ] as OpenAI.Chat.ChatCompletionMessageParam[]; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( mockMessages, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -4720,10 +4718,10 @@ describe('ContentGenerationPipeline', () => { }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Hello' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -4768,10 +4766,10 @@ describe('ContentGenerationPipeline', () => { }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Hello' }, ]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -4812,10 +4810,10 @@ describe('ContentGenerationPipeline', () => { const messages = [ { role: 'user', content: 'Hello' }, ] as OpenAI.Chat.ChatCompletionMessageParam[]; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( messages, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -4848,10 +4846,10 @@ describe('ContentGenerationPipeline', () => { } as unknown as Config; mockConfig.cliConfig = mockCliConfig; pipeline = new ContentGenerationPipeline(mockConfig); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Hello from a subagent' }, ] as OpenAI.Chat.ChatCompletionMessageParam[]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -4887,10 +4885,10 @@ describe('ContentGenerationPipeline', () => { } as unknown as Config; mockConfig.cliConfig = mockCliConfig; pipeline = new ContentGenerationPipeline(mockConfig); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Hello from a fork' }, ] as OpenAI.Chat.ChatCompletionMessageParam[]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -4934,10 +4932,10 @@ describe('ContentGenerationPipeline', () => { { role: 'assistant', content: 'First answer' }, { role: 'user', content: 'Follow-up question' }, ] as OpenAI.Chat.ChatCompletionMessageParam[]; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( messages, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -4971,10 +4969,10 @@ describe('ContentGenerationPipeline', () => { } as unknown as Config; mockConfig.cliConfig = mockCliConfig; pipeline = new ContentGenerationPipeline(mockConfig); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'Hello' }, ] as OpenAI.Chat.ChatCompletionMessageParam[]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -5011,10 +5009,10 @@ describe('ContentGenerationPipeline', () => { { role: 'assistant', content: 'main response' }, { role: 'user', content: 'compression directive' }, ] as OpenAI.Chat.ChatCompletionMessageParam[]; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( messages, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -5054,10 +5052,10 @@ describe('ContentGenerationPipeline', () => { { role: 'assistant', content: 'main response' }, { role: 'user', content: 'compression directive' }, ] as OpenAI.Chat.ChatCompletionMessageParam[]; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( messages, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -5098,13 +5096,13 @@ describe('ContentGenerationPipeline', () => { } as unknown as Config; mockConfig.cliConfig = mockCliConfig; pipeline = new ContentGenerationPipeline(mockConfig); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'system', content: 'system' }, { role: 'user', content: 'main request' }, { role: 'assistant', content: 'main response' }, { role: 'user', content: 'compression directive' }, ] as OpenAI.Chat.ChatCompletionMessageParam[]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -5147,8 +5145,8 @@ describe('ContentGenerationPipeline', () => { contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], config: { maxOutputTokens: 32000 }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -5188,8 +5186,8 @@ describe('ContentGenerationPipeline', () => { contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], config: { maxOutputTokens: 50000 }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -5228,8 +5226,8 @@ describe('ContentGenerationPipeline', () => { contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], config: { maxOutputTokens: 40000 }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -5265,8 +5263,8 @@ describe('ContentGenerationPipeline', () => { contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], config: { maxOutputTokens: 777 }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -5297,8 +5295,8 @@ describe('ContentGenerationPipeline', () => { contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], config: { temperature: 0.5, topP: 0.6, maxOutputTokens: 2048 }, }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -5331,8 +5329,8 @@ describe('ContentGenerationPipeline', () => { const userPromptId = 'test-prompt-id'; const mockOpenAIResponse = new GenerateContentResponse(); - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( mockOpenAIResponse, ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -5363,14 +5361,14 @@ describe('ContentGenerationPipeline', () => { }, }; - const mockGeminiResponse = new GenerateContentResponse(); - mockGeminiResponse.candidates = [ + const mockLlmResponse = new GenerateContentResponse(); + mockLlmResponse.candidates = [ { content: { parts: [{ text: 'Hello' }], role: 'model' } }, ]; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock).mockReturnValue( - mockGeminiResponse, + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock).mockReturnValue( + mockLlmResponse, ); (mockClient.chat.completions.create as Mock).mockResolvedValue( mockStream, @@ -5449,8 +5447,8 @@ describe('ContentGenerationPipeline', () => { }; // Mock empty Gemini responses for partial chunks (they get filtered) - const emptyGeminiResponse1 = new GenerateContentResponse(); - emptyGeminiResponse1.candidates = [ + const emptyLlmResponse1 = new GenerateContentResponse(); + emptyLlmResponse1.candidates = [ { content: { parts: [], role: 'model' }, index: 0, @@ -5458,8 +5456,8 @@ describe('ContentGenerationPipeline', () => { }, ]; - const emptyGeminiResponse2 = new GenerateContentResponse(); - emptyGeminiResponse2.candidates = [ + const emptyLlmResponse2 = new GenerateContentResponse(); + emptyLlmResponse2.candidates = [ { content: { parts: [], role: 'model' }, index: 0, @@ -5468,8 +5466,8 @@ describe('ContentGenerationPipeline', () => { ]; // Mock final Gemini response with tool call - const finalGeminiResponse = new GenerateContentResponse(); - finalGeminiResponse.candidates = [ + const finalLlmResponse = new GenerateContentResponse(); + finalLlmResponse.candidates = [ { content: { parts: [ @@ -5490,13 +5488,13 @@ describe('ContentGenerationPipeline', () => { ]; // Setup converter mocks - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ { role: 'user', content: 'test' }, ]); - (mockConverter.convertOpenAIChunkToGemini as Mock) - .mockReturnValueOnce(emptyGeminiResponse1) // First partial chunk -> empty response - .mockReturnValueOnce(emptyGeminiResponse2) // Second partial chunk -> empty response - .mockReturnValueOnce(finalGeminiResponse); // Finish chunk -> complete response + (mockConverter.convertOpenAIChunkToLlm as Mock) + .mockReturnValueOnce(emptyLlmResponse1) // First partial chunk -> empty response + .mockReturnValueOnce(emptyLlmResponse2) // Second partial chunk -> empty response + .mockReturnValueOnce(finalLlmResponse); // Finish chunk -> complete response // Mock stream const mockStream = { @@ -5528,7 +5526,7 @@ describe('ContentGenerationPipeline', () => { // Should only yield the final response (empty ones are filtered) expect(responses).toHaveLength(1); - expect(responses[0]).toBe(finalGeminiResponse); + expect(responses[0]).toBe(finalLlmResponse); }); }); @@ -5542,10 +5540,10 @@ describe('ContentGenerationPipeline', () => { const mockMessages = [ { role: 'user', content: 'Hello' }, ] as OpenAI.Chat.ChatCompletionMessageParam[]; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( mockMessages, ); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -5595,10 +5593,10 @@ describe('ContentGenerationPipeline', () => { const mockMessages = [ { role: 'user', content: 'Hello' }, ] as OpenAI.Chat.ChatCompletionMessageParam[]; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue( mockMessages, ); - (mockConverter.convertOpenAIChunkToGemini as Mock).mockReturnValue( + (mockConverter.convertOpenAIChunkToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); @@ -5643,8 +5641,8 @@ describe('ContentGenerationPipeline', () => { contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], }; - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( new GenerateContentResponse(), ); (mockClient.chat.completions.create as Mock).mockResolvedValue({ @@ -5797,16 +5795,12 @@ describe('ContentGenerationPipeline', () => { } beforeEach(() => { - (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); - (mockConverter.convertOpenAIChunkToGemini as Mock).mockImplementation( - () => { - const r = new GenerateContentResponse(); - r.candidates = [ - { content: { parts: [{ text: 'x' }], role: 'model' } }, - ]; - return r; - }, - ); + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToLlm as Mock).mockImplementation(() => { + const r = new GenerateContentResponse(); + r.candidates = [{ content: { parts: [{ text: 'x' }], role: 'model' } }]; + return r; + }); // Clean baseline: ignore any ambient QWEN_STREAM_IDLE_TIMEOUT_MS / // QWEN_STREAM_MAX_LIFETIME_MS from the dev/CI shell so the // default-timeout tests aren't silently overridden. Env-specific tests diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index c9966410166..d4781da974e 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -575,13 +575,12 @@ export class ContentGenerationPipeline { )) as OpenAI.Chat.ChatCompletion; reportOpenAiResponse(telemetryAttempt, openaiResponse); - const geminiResponse = - OpenAIContentConverter.convertOpenAIResponseToGemini( - openaiResponse, - context, - ); + const llmResponse = OpenAIContentConverter.convertOpenAIResponseToLlm( + openaiResponse, + context, + ); - return geminiResponse; + return llmResponse; } finally { perRequestAc?.abort(); } @@ -787,7 +786,7 @@ export class ContentGenerationPipeline { throw new StreamContentError(errorContent); } - const response = OpenAIContentConverter.convertOpenAIChunkToGemini( + const response = OpenAIContentConverter.convertOpenAIChunkToLlm( chunk, context, ); @@ -1048,7 +1047,7 @@ export class ContentGenerationPipeline { context: RequestContext, isStreaming: boolean, ): Promise { - const messages = OpenAIContentConverter.convertGeminiRequestToOpenAI( + const messages = OpenAIContentConverter.convertLlmRequestToOpenAI( request, context, ); @@ -1076,11 +1075,10 @@ export class ContentGenerationPipeline { // Add tools if present and non-empty. // Some providers reject tools: [] (empty array), so skip when there are no tools. if (request.config?.tools && request.config.tools.length > 0) { - baseRequest.tools = - await OpenAIContentConverter.convertGeminiToolsToOpenAI( - request.config.tools, - this.contentGeneratorConfig.schemaCompliance ?? 'auto', - ); + baseRequest.tools = await OpenAIContentConverter.convertLlmToolsToOpenAI( + request.config.tools, + this.contentGeneratorConfig.schemaCompliance ?? 'auto', + ); // Map Gemini-style toolConfig.functionCallingConfig.mode to OpenAI's // tool_choice so structured side queries (e.g. the AUTO-mode diff --git a/packages/core/src/core/session-recovery.ts b/packages/core/src/core/session-recovery.ts index f87557c07ac..cb50e7ac7de 100644 --- a/packages/core/src/core/session-recovery.ts +++ b/packages/core/src/core/session-recovery.ts @@ -17,7 +17,7 @@ import { import { ORPHAN_TOOL_USE_REPAIR_REASON, repairOrphanedToolUseTurns, -} from './geminiChat.js'; +} from './llm-chat.js'; export type SessionRecoveryKind = | 'clean' diff --git a/packages/core/src/core/stream-transport-retry.ts b/packages/core/src/core/stream-transport-retry.ts index 63644e45336..d09c5aa2622 100644 --- a/packages/core/src/core/stream-transport-retry.ts +++ b/packages/core/src/core/stream-transport-retry.ts @@ -6,7 +6,7 @@ import type { RetryErrorClassification } from '../utils/retryErrorClassification.js'; -// Internal stream retry allow-list. Keep this outside geminiChat.ts because +// Internal stream retry allow-list. Keep this outside llm-chat.ts because // that file is re-exported from the package barrel, and this retry policy is // not part of the public API. export const RETRYABLE_STREAM_TRANSPORT_CODES: ReadonlySet = new Set([ diff --git a/packages/core/src/core/turn-interruption.ts b/packages/core/src/core/turn-interruption.ts index c27395b5474..95a751de164 100644 --- a/packages/core/src/core/turn-interruption.ts +++ b/packages/core/src/core/turn-interruption.ts @@ -118,7 +118,7 @@ export function detectTurnInterruption(history: Content[]): TurnInterruption { /** * Build the error `functionResponse` parts that close the dangling * `functionCall`s of an `interrupted_turn`. Shape matches the repair pass's - * synthesized responses (`applyRepair` in geminiChat.ts) so downstream + * synthesized responses (`applyRepair` in llm-chat.ts) so downstream * dedup and telemetry treat both identically. * * @param danglingCalls - The unanswered calls from {@link detectTurnInterruption}. diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts index 3bbc2caa97f..847bde64bad 100644 --- a/packages/core/src/core/turn.test.ts +++ b/packages/core/src/core/turn.test.ts @@ -6,14 +6,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { - ServerGeminiToolCallRequestEvent, - ServerGeminiErrorEvent, - ServerGeminiModelFallbackEvent, + ServerLlmToolCallRequestEvent, + ServerLlmErrorEvent, + ServerLlmModelFallbackEvent, } from './turn.js'; import { CompressionStatus, Turn, - GeminiEventType, + LlmEventType, createDuplicateProviderToolCallResponse, findRepeatedDuplicateProviderToolCall, } from './turn.js'; @@ -24,8 +24,8 @@ import type { PartListUnion, } from '@google/genai'; import { reportError } from '../utils/errorReporting.js'; -import type { GeminiChat } from './geminiChat.js'; -import { StreamEventType } from './geminiChat.js'; +import type { LlmChat } from './llm-chat.js'; +import { StreamEventType } from './llm-chat.js'; import { normalizeModelToolCallIds } from './toolCallIdUtils.js'; import { createOpenAIReasoningThoughtPart } from '../utils/thoughtUtils.js'; @@ -156,7 +156,7 @@ describe('Turn', () => { getHistoryTailShallow: mockGetHistoryTailShallow, maybeIncludeSchemaDepthContext: mockMaybeIncludeSchemaDepthContext, }; - turn = new Turn(mockChatInstance as unknown as GeminiChat, 'prompt-id-1'); + turn = new Turn(mockChatInstance as unknown as LlmChat, 'prompt-id-1'); mockGetHistory.mockReturnValue([]); mockGetHistoryLength.mockReturnValue(0); mockGetHistoryTailShallow.mockReturnValue([]); @@ -212,8 +212,8 @@ describe('Turn', () => { ); expect(events).toEqual([ - { type: GeminiEventType.Content, value: 'Hello' }, - { type: GeminiEventType.Content, value: ' world' }, + { type: LlmEventType.Content, value: 'Hello' }, + { type: LlmEventType.Content, value: ' world' }, ]); }); @@ -275,11 +275,11 @@ describe('Turn', () => { expect(events).toEqual([ { - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: { subject: '', description: 'hidden' }, }, { - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'beforeafter', parts: [ { text: 'before' }, @@ -294,7 +294,7 @@ describe('Turn', () => { ], }, { - type: GeminiEventType.Content, + type: LlmEventType.Content, value: '', parts: [ { @@ -341,10 +341,10 @@ describe('Turn', () => { expect(events).toEqual([ { - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: { subject: '', description: 'reasoning...' }, }, - { type: GeminiEventType.Content, value: 'final answer' }, + { type: LlmEventType.Content, value: 'final answer' }, ]); }); @@ -381,7 +381,7 @@ describe('Turn', () => { expect(events).toEqual([ { - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: { subject: '', description: '**Analyzing the request**' }, }, ]); @@ -416,7 +416,7 @@ describe('Turn', () => { expect(events).toEqual([ { - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: { subject: 'Only Subject', description: '' }, }, ]); @@ -464,11 +464,11 @@ describe('Turn', () => { expect(events).toEqual([ { - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: { subject: '', description: 'part1' }, }, { - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: { subject: '', description: 'part2' }, }, ]); @@ -508,8 +508,8 @@ describe('Turn', () => { } expect(events.length).toBe(2); - const event1 = events[0] as ServerGeminiToolCallRequestEvent; - expect(event1.type).toBe(GeminiEventType.ToolCallRequest); + const event1 = events[0] as ServerLlmToolCallRequestEvent; + expect(event1.type).toBe(LlmEventType.ToolCallRequest); expect(event1.value).toEqual( expect.objectContaining({ callId: 'fc1', @@ -520,8 +520,8 @@ describe('Turn', () => { ); expect(turn.pendingToolCalls[0]).toEqual(event1.value); - const event2 = events[1] as ServerGeminiToolCallRequestEvent; - expect(event2.type).toBe(GeminiEventType.ToolCallRequest); + const event2 = events[1] as ServerLlmToolCallRequestEvent; + expect(event2.type).toBe(LlmEventType.ToolCallRequest); expect(event2.value).toEqual( expect.objectContaining({ name: 'tool2', @@ -583,15 +583,15 @@ describe('Turn', () => { } const toolCalls = events.filter( - (event): event is ServerGeminiToolCallRequestEvent => - event.type === GeminiEventType.ToolCallRequest, + (event): event is ServerLlmToolCallRequestEvent => + event.type === LlmEventType.ToolCallRequest, ); const fallbackEvent = events.find( - (event): event is ServerGeminiModelFallbackEvent => - event.type === GeminiEventType.ModelFallback, + (event): event is ServerLlmModelFallbackEvent => + event.type === LlmEventType.ModelFallback, ); expect(fallbackEvent).toEqual({ - type: GeminiEventType.ModelFallback, + type: LlmEventType.ModelFallback, fromModel: 'primary-model', toModel: 'fallback-model', statusCode: undefined, @@ -637,8 +637,8 @@ describe('Turn', () => { events.push(event); } expect(events).toEqual([ - { type: GeminiEventType.Content, value: 'First part' }, - { type: GeminiEventType.UserCancelled }, + { type: LlmEventType.Content, value: 'First part' }, + { type: LlmEventType.UserCancelled }, ]); }); @@ -662,8 +662,8 @@ describe('Turn', () => { } expect(events.length).toBe(1); - const errorEvent = events[0] as ServerGeminiErrorEvent; - expect(errorEvent.type).toBe(GeminiEventType.Error); + const errorEvent = events[0] as ServerLlmErrorEvent; + expect(errorEvent.type).toBe(LlmEventType.Error); expect(errorEvent.value).toEqual({ error: { message: 'API Error', status: undefined }, }); @@ -719,7 +719,7 @@ describe('Turn', () => { expect(events).toEqual([ { - type: GeminiEventType.Error, + type: LlmEventType.Error, value: { error: { message: 'Code Assist is not enabled', @@ -749,7 +749,7 @@ describe('Turn', () => { expect(events).toEqual([ { - type: GeminiEventType.Error, + type: LlmEventType.Error, value: { error: { message: expect.any(String), @@ -775,8 +775,8 @@ describe('Turn', () => { events.push(event); } - const errorEvent = events[0] as ServerGeminiErrorEvent; - expect(errorEvent.type).toBe(GeminiEventType.Error); + const errorEvent = events[0] as ServerLlmErrorEvent; + expect(errorEvent.type).toBe(LlmEventType.Error); expect(errorEvent.value).toEqual({ error: { message: 'API Error', status: undefined }, }); @@ -836,7 +836,7 @@ describe('Turn', () => { events.push(event); } - expect(events[0]?.type).toBe(GeminiEventType.Error); + expect(events[0]?.type).toBe(LlmEventType.Error); expect(mockGetHistory).not.toHaveBeenCalled(); expect(mockGetHistoryLength).toHaveBeenCalled(); expect(mockGetHistoryTailShallow).toHaveBeenCalledWith(8, true); @@ -899,7 +899,7 @@ describe('Turn', () => { events.push(event); } - expect(events[0]?.type).toBe(GeminiEventType.Error); + expect(events[0]?.type).toBe(LlmEventType.Error); expect(reportError).toHaveBeenCalledWith( error, 'Error when talking to API', @@ -938,7 +938,7 @@ describe('Turn', () => { events.push(event); } - expect(events[0]?.type).toBe(GeminiEventType.Error); + expect(events[0]?.type).toBe(LlmEventType.Error); expect(reportError).toHaveBeenCalledWith( error, 'Error when talking to API', @@ -991,21 +991,21 @@ describe('Turn', () => { expect(events.length).toBe(3); // Assertions for each specific tool call event - const event1 = events[0] as ServerGeminiToolCallRequestEvent; + const event1 = events[0] as ServerLlmToolCallRequestEvent; expect(event1.value).toMatchObject({ callId: 'fc1', name: 'undefined_tool_name', args: { arg1: 'val1' }, }); - const event2 = events[1] as ServerGeminiToolCallRequestEvent; + const event2 = events[1] as ServerLlmToolCallRequestEvent; expect(event2.value).toMatchObject({ callId: 'fc2', name: 'tool2', args: {}, }); - const event3 = events[2] as ServerGeminiToolCallRequestEvent; + const event3 = events[2] as ServerLlmToolCallRequestEvent; expect(event3.value).toMatchObject({ callId: 'fc3', name: 'undefined_tool_name', @@ -1039,7 +1039,7 @@ describe('Turn', () => { expect(events.length).toBe(2); - const event1 = events[0] as ServerGeminiToolCallRequestEvent; + const event1 = events[0] as ServerLlmToolCallRequestEvent; expect(event1.value).toMatchObject({ callId: 'fc1', providerCallId: 'fc1', @@ -1047,7 +1047,7 @@ describe('Turn', () => { args: { arg1: 'val1' }, }); - const event2 = events[1] as ServerGeminiToolCallRequestEvent; + const event2 = events[1] as ServerLlmToolCallRequestEvent; expect(event2.value.callId).toMatch(/^tool2-/); expect(event2.value.providerCallId).toBeUndefined(); expect(event2.value).toMatchObject({ @@ -1091,7 +1091,7 @@ describe('Turn', () => { } expect(events.length).toBe(1); - const event = events[0] as ServerGeminiToolCallRequestEvent; + const event = events[0] as ServerLlmToolCallRequestEvent; expect(event.value).toMatchObject({ callId: 'fc1__qwen_dup_2', providerCallId: 'fc1', @@ -1132,9 +1132,9 @@ describe('Turn', () => { } expect(events).toEqual([ - { type: GeminiEventType.Content, value: 'Partial response' }, + { type: LlmEventType.Content, value: 'Partial response' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: 'STOP', usageMetadata: { @@ -1180,11 +1180,11 @@ describe('Turn', () => { expect(events).toEqual([ { - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'This is a long response that was cut off...', }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: 'MAX_TOKENS', usageMetadata: undefined }, }, ]); @@ -1217,9 +1217,9 @@ describe('Turn', () => { } expect(events).toEqual([ - { type: GeminiEventType.Content, value: 'Content blocked' }, + { type: LlmEventType.Content, value: 'Content blocked' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: 'SAFETY', usageMetadata: undefined }, }, ]); @@ -1255,7 +1255,7 @@ describe('Turn', () => { expect(events).toEqual([ { - type: GeminiEventType.Content, + type: LlmEventType.Content, value: 'Response without finish reason', }, ]); @@ -1299,10 +1299,10 @@ describe('Turn', () => { } expect(events).toEqual([ - { type: GeminiEventType.Content, value: 'First part' }, - { type: GeminiEventType.Content, value: 'Second part' }, + { type: LlmEventType.Content, value: 'First part' }, + { type: LlmEventType.Content, value: 'Second part' }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: 'OTHER', usageMetadata: undefined }, }, ]); @@ -1342,13 +1342,13 @@ describe('Turn', () => { } expect(events).toEqual([ - { type: GeminiEventType.Content, value: 'Some text.' }, + { type: LlmEventType.Content, value: 'Some text.' }, { - type: GeminiEventType.Citation, + type: LlmEventType.Citation, value: 'Citations:\n(Source 1 Title) https://example.com/source1', }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }, ]); @@ -1392,14 +1392,14 @@ describe('Turn', () => { } expect(events).toEqual([ - { type: GeminiEventType.Content, value: 'Some text.' }, + { type: LlmEventType.Content, value: 'Some text.' }, { - type: GeminiEventType.Citation, + type: LlmEventType.Citation, value: 'Citations:\n(Title1) https://example.com/source1\n(Title2) https://example.com/source2', }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }, ]); @@ -1439,12 +1439,10 @@ describe('Turn', () => { } expect(events).toEqual([ - { type: GeminiEventType.Content, value: 'Some text.' }, + { type: LlmEventType.Content, value: 'Some text.' }, ]); // No Citation event (but we do get a Finished event with undefined reason) - expect(events.some((e) => e.type === GeminiEventType.Citation)).toBe( - false, - ); + expect(events.some((e) => e.type === LlmEventType.Citation)).toBe(false); }); it('should ignore citations without a URI', async () => { @@ -1485,13 +1483,13 @@ describe('Turn', () => { } expect(events).toEqual([ - { type: GeminiEventType.Content, value: 'Some text.' }, + { type: LlmEventType.Content, value: 'Some text.' }, { - type: GeminiEventType.Citation, + type: LlmEventType.Citation, value: 'Citations:\n(Good Source) https://example.com/source1', }, { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: 'STOP', usageMetadata: undefined }, }, ]); @@ -1522,7 +1520,7 @@ describe('Turn', () => { events.push(event); } - expect(events).toEqual([{ type: GeminiEventType.UserCancelled }]); + expect(events).toEqual([{ type: LlmEventType.UserCancelled }]); expect(reportError).not.toHaveBeenCalled(); }); @@ -1549,8 +1547,8 @@ describe('Turn', () => { } expect(events).toEqual([ - { type: GeminiEventType.Retry }, - { type: GeminiEventType.Content, value: 'Success' }, + { type: LlmEventType.Retry }, + { type: LlmEventType.Content, value: 'Success' }, ]); }); @@ -1581,8 +1579,8 @@ describe('Turn', () => { } expect(events).toEqual([ - { type: GeminiEventType.ChatCompressed, value: compressionInfo }, - { type: GeminiEventType.Content, value: 'after' }, + { type: LlmEventType.ChatCompressed, value: compressionInfo }, + { type: LlmEventType.Content, value: 'after' }, ]); }); }); diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 5d805fd652e..5048b7b20bc 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -30,7 +30,7 @@ import { UnauthorizedError, toFriendlyError, } from '../utils/errors.js'; -import type { GeminiChat } from './geminiChat.js'; +import type { LlmChat } from './llm-chat.js'; import type { RetryInfo } from '../utils/rateLimit.js'; import { getThoughtSummary, @@ -59,7 +59,7 @@ export interface ServerTool { ): Promise; } -export enum GeminiEventType { +export enum LlmEventType { Content = 'content', ToolCallRequest = 'tool_call_request', ToolCallResponse = 'tool_call_response', @@ -84,16 +84,19 @@ export enum GeminiEventType { ModelFallback = 'model_fallback', } -export type ServerGeminiRetryEvent = { - type: GeminiEventType.Retry; +/** @deprecated Use `LlmEventType`; retained until a future major release. */ +export { LlmEventType as GeminiEventType }; + +export type ServerLlmRetryEvent = { + type: LlmEventType.Retry; retryInfo?: RetryInfo; /** When true, the retry is a continuation (recovery) rather than a fresh * restart. The UI should keep accumulated text so the continuation appends. */ isContinuation?: boolean; }; -export type ServerGeminiModelFallbackEvent = { - type: GeminiEventType.ModelFallback; +export type ServerLlmModelFallbackEvent = { + type: LlmEventType.ModelFallback; /** The model that exhausted its retry budget. */ fromModel: string; /** The model the system is switching to. */ @@ -210,7 +213,7 @@ function summarizeHistoryEntry(content: Content) { }; } -function buildApiErrorReportContext(chat: GeminiChat, req: PartListUnion) { +function buildApiErrorReportContext(chat: LlmChat, req: PartListUnion) { const requestParts = normalizeRequestParts(req); return { history: { @@ -308,7 +311,7 @@ export interface ServerToolCallConfirmationDetails { details: ToolCallConfirmationDetails; } -export type ServerGeminiContentPart = +export type ServerLlmContentPart = | { text: string } | { inlineData: { @@ -318,39 +321,39 @@ export type ServerGeminiContentPart = }; }; -export type ServerGeminiContentEvent = { - type: GeminiEventType.Content; +export type ServerLlmContentEvent = { + type: LlmEventType.Content; value: string; /** Ordered display parts, present only when the chunk contains an image. */ - parts?: ServerGeminiContentPart[]; + parts?: ServerLlmContentPart[]; }; -export type ServerGeminiThoughtEvent = { - type: GeminiEventType.Thought; +export type ServerLlmThoughtEvent = { + type: LlmEventType.Thought; value: ThoughtSummary; }; -export type ServerGeminiToolCallRequestEvent = { - type: GeminiEventType.ToolCallRequest; +export type ServerLlmToolCallRequestEvent = { + type: LlmEventType.ToolCallRequest; value: ToolCallRequestInfo; }; -export type ServerGeminiToolCallResponseEvent = { - type: GeminiEventType.ToolCallResponse; +export type ServerLlmToolCallResponseEvent = { + type: LlmEventType.ToolCallResponse; value: ToolCallResponseInfo; }; -export type ServerGeminiToolCallConfirmationEvent = { - type: GeminiEventType.ToolCallConfirmation; +export type ServerLlmToolCallConfirmationEvent = { + type: LlmEventType.ToolCallConfirmation; value: ServerToolCallConfirmationDetails; }; -export type ServerGeminiUserCancelledEvent = { - type: GeminiEventType.UserCancelled; +export type ServerLlmUserCancelledEvent = { + type: LlmEventType.UserCancelled; }; -export type ServerGeminiErrorEvent = { - type: GeminiEventType.Error; +export type ServerLlmErrorEvent = { + type: LlmEventType.Error; value: LlmErrorEventValue; }; @@ -407,27 +410,27 @@ export interface ChatCompressionInfo { warning?: string; } -export type ServerGeminiChatCompressedEvent = { - type: GeminiEventType.ChatCompressed; +export type ServerLlmChatCompressedEvent = { + type: LlmEventType.ChatCompressed; value: ChatCompressionInfo | null; }; -export type ServerGeminiMaxSessionTurnsEvent = { - type: GeminiEventType.MaxSessionTurns; +export type ServerLlmMaxSessionTurnsEvent = { + type: LlmEventType.MaxSessionTurns; }; -export type ServerGeminiSessionTokenLimitExceededEvent = { - type: GeminiEventType.SessionTokenLimitExceeded; +export type ServerLlmSessionTokenLimitExceededEvent = { + type: LlmEventType.SessionTokenLimitExceeded; value: SessionTokenLimitExceededValue; }; -export type ServerGeminiFinishedEvent = { - type: GeminiEventType.Finished; +export type ServerLlmFinishedEvent = { + type: LlmEventType.Finished; value: LlmFinishedEventValue; }; -export type ServerGeminiLoopDetectedEvent = { - type: GeminiEventType.LoopDetected; +export type ServerLlmLoopDetectedEvent = { + type: LlmEventType.LoopDetected; // The loop type is optional so historical call sites that don't produce one // (tests, fixtures) stay valid. Real emissions in client.ts always populate // it so downstream consumers can surface a concrete reason to the user. @@ -436,26 +439,26 @@ export type ServerGeminiLoopDetectedEvent = { }; }; -export type ServerGeminiCitationEvent = { - type: GeminiEventType.Citation; +export type ServerLlmCitationEvent = { + type: LlmEventType.Citation; value: string; }; -export type ServerGeminiHookSystemMessageEvent = { - type: GeminiEventType.HookSystemMessage; +export type ServerLlmHookSystemMessageEvent = { + type: LlmEventType.HookSystemMessage; value: string; }; -export type ServerGeminiUserPromptSubmitBlockedEvent = { - type: GeminiEventType.UserPromptSubmitBlocked; +export type ServerLlmUserPromptSubmitBlockedEvent = { + type: LlmEventType.UserPromptSubmitBlocked; value: { reason: string; originalPrompt: string; }; }; -export type ServerGeminiStopHookLoopEvent = { - type: GeminiEventType.StopHookLoop; +export type ServerLlmStopHookLoopEvent = { + type: LlmEventType.StopHookLoop; value: { iterationCount: number; reasons: string[]; @@ -463,45 +466,94 @@ export type ServerGeminiStopHookLoopEvent = { }; }; -export type ServerGeminiActiveGoalEvent = { - type: GeminiEventType.ActiveGoal; +export type ServerLlmActiveGoalEvent = { + type: LlmEventType.ActiveGoal; value: ActiveGoal | null; }; -export type ServerGeminiGoalStateEvent = { - type: GeminiEventType.GoalState; +export type ServerLlmGoalStateEvent = { + type: LlmEventType.GoalState; value: GoalSnapshotV2; cause?: GoalStateCause; }; // The original union type, now composed of the individual types -export type ServerGeminiStreamEvent = - | ServerGeminiGoalStateEvent - | ServerGeminiActiveGoalEvent - | ServerGeminiChatCompressedEvent - | ServerGeminiCitationEvent - | ServerGeminiContentEvent - | ServerGeminiErrorEvent - | ServerGeminiFinishedEvent - | ServerGeminiHookSystemMessageEvent - | ServerGeminiUserPromptSubmitBlockedEvent - | ServerGeminiStopHookLoopEvent - | ServerGeminiLoopDetectedEvent - | ServerGeminiMaxSessionTurnsEvent - | ServerGeminiModelFallbackEvent - | ServerGeminiThoughtEvent - | ServerGeminiToolCallConfirmationEvent - | ServerGeminiToolCallRequestEvent - | ServerGeminiToolCallResponseEvent - | ServerGeminiUserCancelledEvent - | ServerGeminiSessionTokenLimitExceededEvent - | ServerGeminiRetryEvent; +export type ServerLlmStreamEvent = + | ServerLlmGoalStateEvent + | ServerLlmActiveGoalEvent + | ServerLlmChatCompressedEvent + | ServerLlmCitationEvent + | ServerLlmContentEvent + | ServerLlmErrorEvent + | ServerLlmFinishedEvent + | ServerLlmHookSystemMessageEvent + | ServerLlmUserPromptSubmitBlockedEvent + | ServerLlmStopHookLoopEvent + | ServerLlmLoopDetectedEvent + | ServerLlmMaxSessionTurnsEvent + | ServerLlmModelFallbackEvent + | ServerLlmThoughtEvent + | ServerLlmToolCallConfirmationEvent + | ServerLlmToolCallRequestEvent + | ServerLlmToolCallResponseEvent + | ServerLlmUserCancelledEvent + | ServerLlmSessionTokenLimitExceededEvent + | ServerLlmRetryEvent; + +/** @deprecated Use `ServerLlmRetryEvent`; retained until a future major release. */ +export type ServerGeminiRetryEvent = ServerLlmRetryEvent; +/** @deprecated Use `ServerLlmModelFallbackEvent`; retained until a future major release. */ +export type ServerGeminiModelFallbackEvent = ServerLlmModelFallbackEvent; +/** @deprecated Use `ServerLlmContentPart`; retained until a future major release. */ +export type ServerGeminiContentPart = ServerLlmContentPart; +/** @deprecated Use `ServerLlmContentEvent`; retained until a future major release. */ +export type ServerGeminiContentEvent = ServerLlmContentEvent; +/** @deprecated Use `ServerLlmThoughtEvent`; retained until a future major release. */ +export type ServerGeminiThoughtEvent = ServerLlmThoughtEvent; +/** @deprecated Use `ServerLlmToolCallRequestEvent`; retained until a future major release. */ +export type ServerGeminiToolCallRequestEvent = ServerLlmToolCallRequestEvent; +/** @deprecated Use `ServerLlmToolCallResponseEvent`; retained until a future major release. */ +export type ServerGeminiToolCallResponseEvent = ServerLlmToolCallResponseEvent; +/** @deprecated Use `ServerLlmToolCallConfirmationEvent`; retained until a future major release. */ +export type ServerGeminiToolCallConfirmationEvent = + ServerLlmToolCallConfirmationEvent; +/** @deprecated Use `ServerLlmUserCancelledEvent`; retained until a future major release. */ +export type ServerGeminiUserCancelledEvent = ServerLlmUserCancelledEvent; +/** @deprecated Use `ServerLlmErrorEvent`; retained until a future major release. */ +export type ServerGeminiErrorEvent = ServerLlmErrorEvent; +/** @deprecated Use `ServerLlmChatCompressedEvent`; retained until a future major release. */ +export type ServerGeminiChatCompressedEvent = ServerLlmChatCompressedEvent; +/** @deprecated Use `ServerLlmMaxSessionTurnsEvent`; retained until a future major release. */ +export type ServerGeminiMaxSessionTurnsEvent = ServerLlmMaxSessionTurnsEvent; +/** @deprecated Use `ServerLlmSessionTokenLimitExceededEvent`; retained until a future major release. */ +export type ServerGeminiSessionTokenLimitExceededEvent = + ServerLlmSessionTokenLimitExceededEvent; +/** @deprecated Use `ServerLlmFinishedEvent`; retained until a future major release. */ +export type ServerGeminiFinishedEvent = ServerLlmFinishedEvent; +/** @deprecated Use `ServerLlmLoopDetectedEvent`; retained until a future major release. */ +export type ServerGeminiLoopDetectedEvent = ServerLlmLoopDetectedEvent; +/** @deprecated Use `ServerLlmCitationEvent`; retained until a future major release. */ +export type ServerGeminiCitationEvent = ServerLlmCitationEvent; +/** @deprecated Use `ServerLlmHookSystemMessageEvent`; retained until a future major release. */ +export type ServerGeminiHookSystemMessageEvent = + ServerLlmHookSystemMessageEvent; +/** @deprecated Use `ServerLlmUserPromptSubmitBlockedEvent`; retained until a future major release. */ +export type ServerGeminiUserPromptSubmitBlockedEvent = + ServerLlmUserPromptSubmitBlockedEvent; +/** @deprecated Use `ServerLlmStopHookLoopEvent`; retained until a future major release. */ +export type ServerGeminiStopHookLoopEvent = ServerLlmStopHookLoopEvent; +/** @deprecated Use `ServerLlmActiveGoalEvent`; retained until a future major release. */ +export type ServerGeminiActiveGoalEvent = ServerLlmActiveGoalEvent; +/** @deprecated Use `ServerLlmGoalStateEvent`; retained until a future major release. */ +export type ServerGeminiGoalStateEvent = ServerLlmGoalStateEvent; +/** @deprecated Use `ServerLlmStreamEvent`; retained until a future major release. */ +export type ServerGeminiStreamEvent = ServerLlmStreamEvent; function getDisplayContentParts( response: GenerateContentResponse, -): ServerGeminiContentPart[] { +): ServerLlmContentPart[] { const parts = response.candidates?.[0]?.content?.parts ?? []; - const displayParts: ServerGeminiContentPart[] = []; + const displayParts: ServerLlmContentPart[] = []; for (const part of parts) { if (part.thought) { @@ -540,7 +592,7 @@ export class Turn { private readonly goalContext?: GoalTurnPermit; constructor( - private readonly chat: GeminiChat, + private readonly chat: LlmChat, private readonly prompt_id: string, goalContext?: GoalTurnPermit, ) { @@ -551,7 +603,7 @@ export class Turn { model: string, req: PartListUnion, signal: AbortSignal, - ): AsyncGenerator { + ): AsyncGenerator { try { // Note: This assumes `sendMessageStream` yields events like // { type: StreamEventType.RETRY } or { type: StreamEventType.CHUNK, value: GenerateContentResponse } @@ -569,7 +621,7 @@ export class Turn { for await (const streamEvent of responseStream) { if (signal?.aborted) { - yield { type: GeminiEventType.UserCancelled }; + yield { type: LlmEventType.UserCancelled }; return; } @@ -580,7 +632,7 @@ export class Turn { this.pendingCitations.clear(); this.finishReason = undefined; yield { - type: GeminiEventType.Retry, + type: LlmEventType.Retry, retryInfo: streamEvent.retryInfo, isContinuation: streamEvent.isContinuation, }; @@ -597,7 +649,7 @@ export class Turn { this.finishReason = undefined; this.currentResponseId = undefined; yield { - type: GeminiEventType.ModelFallback, + type: LlmEventType.ModelFallback, fromModel: streamEvent.info.fromModel, toModel: streamEvent.info.toModel, statusCode: streamEvent.info.statusCode, @@ -610,10 +662,10 @@ export class Turn { // as the top-level ChatCompressed event so existing UI handlers stay // connected. This bridge is the primary path for auto-compaction // events; manual /compress emits its own ChatCompressed in - // GeminiClient.tryCompressChat. + // LlmClient.tryCompressChat. if (streamEvent.type === 'compressed') { yield { - type: GeminiEventType.ChatCompressed, + type: LlmEventType.ChatCompressed, value: streamEvent.info, }; continue; @@ -631,7 +683,7 @@ export class Turn { const thoughtSummary = getThoughtSummary(resp); if (thoughtSummary) { yield { - type: GeminiEventType.Thought, + type: LlmEventType.Thought, value: thoughtSummary, }; } @@ -641,7 +693,7 @@ export class Turn { const hasImage = displayParts.some((part) => 'inlineData' in part); if (text || hasImage) { yield { - type: GeminiEventType.Content, + type: LlmEventType.Content, value: text, ...(hasImage ? { parts: displayParts } : {}), }; @@ -675,7 +727,7 @@ export class Turn { if (this.pendingCitations.size > 0) { yield { - type: GeminiEventType.Citation, + type: LlmEventType.Citation, value: `Citations:\n${[...this.pendingCitations].sort().join('\n')}`, }; this.pendingCitations.clear(); @@ -683,7 +735,7 @@ export class Turn { this.finishReason = finishReason; yield { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: finishReason, usageMetadata: resp.usageMetadata, @@ -693,7 +745,7 @@ export class Turn { } } catch (e) { if (signal.aborted) { - yield { type: GeminiEventType.UserCancelled }; + yield { type: LlmEventType.UserCancelled }; // Regular cancellation error, fail gracefully. return; } @@ -731,14 +783,14 @@ export class Turn { status: getErrorStatus(error) ?? originalStatus, }; await this.chat.maybeIncludeSchemaDepthContext(structuredError); - yield { type: GeminiEventType.Error, value: { error: structuredError } }; + yield { type: LlmEventType.Error, value: { error: structuredError } }; return; } } private handlePendingFunctionCall( fnCall: FunctionCall, - ): ServerGeminiStreamEvent | null { + ): ServerLlmStreamEvent | null { const callId = fnCall.id ?? `${fnCall.name}-${Date.now()}-${Math.random().toString(16).slice(2)}`; @@ -760,7 +812,7 @@ export class Turn { this.pendingToolCalls.push(toolCallRequest); // Yield a request for the tool call, not the pending/confirming status - return { type: GeminiEventType.ToolCallRequest, value: toolCallRequest }; + return { type: LlmEventType.ToolCallRequest, value: toolCallRequest }; } } diff --git a/packages/core/src/extension/extension-runtime-refresh.ts b/packages/core/src/extension/extension-runtime-refresh.ts index a1f7d273e07..c0943170785 100644 --- a/packages/core/src/extension/extension-runtime-refresh.ts +++ b/packages/core/src/extension/extension-runtime-refresh.ts @@ -28,7 +28,7 @@ export async function refreshExtensionRuntime( // MCP servers must settle first — skills and subagents may depend on the // updated MCP tool list for their own refresh (e.g. SkillTool.refreshSkills() - // rebuilds the model-facing tool description and updates geminiClient's tool + // rebuilds the model-facing tool description and updates llmClient's tool // list). A failure here is user-visible because extension MCP tools will be // unavailable, so let callers surface it. await config.reinitializeMcpServers(config.getSettingsMcpServers()); diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index b2fc7228bd9..5e356fe01d2 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -4115,7 +4115,7 @@ describe('extension tests', () => { const mockSettingsMcpServers = { server: { command: 'cmd' } }; const mockConfig = { - getGeminiClient: () => ({ + getLlmClient: () => ({ isInitialized: () => false, setTools: vi.fn(), }), diff --git a/packages/core/src/followup/speculation.ts b/packages/core/src/followup/speculation.ts index dcd4ea4915a..c9f0ca1c9e6 100644 --- a/packages/core/src/followup/speculation.ts +++ b/packages/core/src/followup/speculation.ts @@ -6,7 +6,7 @@ * Speculation Engine * * Speculatively executes the accepted suggestion before the user confirms, - * using a forked GeminiChat with copy-on-write file isolation. + * using a forked LlmChat with copy-on-write file isolation. * * Flow: * 1. Suggestion shown → startSpeculation() fires @@ -17,9 +17,9 @@ import type { Content, Part } from '@google/genai'; import type { Config } from '../config/config.js'; -import type { GeminiClient } from '../core/client.js'; +import type { LlmClient } from '../core/client.js'; import type { ToolArtifact } from '../tools/tools.js'; -import { StreamEventType } from '../core/geminiChat.js'; +import { StreamEventType } from '../core/llm-chat.js'; import { convertToFunctionErrorResponse, convertToFunctionResponse, @@ -586,7 +586,7 @@ async function runSpeculativeLoop( */ export async function acceptSpeculation( state: SpeculationState, - geminiClient: GeminiClient, + llmClient: LlmClient, ): Promise { const timeSavedMs = state.boundary ? Math.max(0, state.boundary.completedAt - state.startTime) @@ -603,7 +603,7 @@ export async function acceptSpeculation( // Inject into main conversation for (const msg of cleanMessages) { - await geminiClient.addHistory(msg); + await llmClient.addHistory(msg); } state.status = 'completed'; diff --git a/packages/core/src/goals/goalJudge.test.ts b/packages/core/src/goals/goalJudge.test.ts index 52fa3149f0c..0228bfe9cf3 100644 --- a/packages/core/src/goals/goalJudge.test.ts +++ b/packages/core/src/goals/goalJudge.test.ts @@ -50,7 +50,7 @@ function makeConfig(opts: { model?: string; }): Config { return { - getGeminiClient: () => opts.client, + getLlmClient: () => opts.client, getFastModel: () => opts.fastModel, getModel: () => opts.model ?? 'main-model', } as unknown as Config; diff --git a/packages/core/src/goals/goalJudge.ts b/packages/core/src/goals/goalJudge.ts index 862c64a9d47..1a1c1415a85 100644 --- a/packages/core/src/goals/goalJudge.ts +++ b/packages/core/src/goals/goalJudge.ts @@ -200,7 +200,7 @@ export async function judgeGoal( const model = config.getFastModel() ?? config.getModel(); try { - const client = config.getGeminiClient(); + const client = config.getLlmClient(); const response = await client.generateContent( transcript, { @@ -256,7 +256,7 @@ function collectTranscript( lastAssistantText: string, ): Content[] { try { - const client = config.getGeminiClient(); + const client = config.getLlmClient(); if (!client.isInitialized()) return fallbackTranscript(lastAssistantText); const full = client.getHistoryTail(TRANSCRIPT_TAIL_MESSAGES); const tail = full.map(capContent); diff --git a/packages/core/src/goals/goalLoop.integration.test.ts b/packages/core/src/goals/goalLoop.integration.test.ts index 0e42f2eb0e3..8dcd6834d83 100644 --- a/packages/core/src/goals/goalLoop.integration.test.ts +++ b/packages/core/src/goals/goalLoop.integration.test.ts @@ -7,7 +7,7 @@ /** * Integration test for the /goal Stop hook loop. * - * This intentionally does NOT boot `GeminiClient` or the full hook runner. + * This intentionally does NOT boot `LlmClient` or the full hook runner. * It exercises the seam that matters for the spec criterion: * * "after `/goal `, a normal attempt to stop must be intercepted diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts index 154c96a6a2e..6f4e98adfb5 100644 --- a/packages/core/src/index.test.ts +++ b/packages/core/src/index.test.ts @@ -4,10 +4,22 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; +import { + GeminiChat, + GeminiClient, + GeminiEventType, + LlmChat, + LlmClient, + LlmEventType, +} from './index.js'; +import { LlmChat as LegacyPathLlmChat } from './core/geminiChat.js'; -describe('placeholder tests', () => { - it('should pass', () => { - expect(true).toBe(true); +describe('deprecated LLM rename aliases', () => { + it('keeps the published class, enum, and module-path aliases', () => { + expect(GeminiClient).toBe(LlmClient); + expect(GeminiChat).toBe(LlmChat); + expect(GeminiEventType).toBe(LlmEventType); + expect(LegacyPathLlmChat).toBe(LlmChat); }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fdf86b6e596..168825db849 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -83,7 +83,7 @@ export { PLAN_MODE_ENTRY_SIBLING_SKIP_MESSAGE, findPlanModeEntryBatchBoundaryIndex, } from './core/plan-mode-entry-policy.js'; -export * from './core/geminiChat.js'; +export * from './core/llm-chat.js'; export * from './core/llm-request.js'; export * from './core/inlineMediaLimit.js'; export * from './core/insightProtocol.js'; diff --git a/packages/core/src/memory/refresh.test.ts b/packages/core/src/memory/refresh.test.ts index 18cd74b0f92..f10e639eab1 100644 --- a/packages/core/src/memory/refresh.test.ts +++ b/packages/core/src/memory/refresh.test.ts @@ -40,7 +40,7 @@ function createConfig(projectRoot: string, managed = true): Config { isManagedMemoryAvailable: vi.fn().mockReturnValue(managed), getProjectRoot: vi.fn().mockReturnValue(projectRoot), refreshHierarchicalMemory: vi.fn().mockResolvedValue(undefined), - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ refreshSystemInstruction: vi.fn().mockResolvedValue(undefined), }), } as unknown as Config; @@ -309,7 +309,7 @@ describe('managed memory refresh helper', () => { expect(rebuildUserAutoMemoryIndex).toHaveBeenCalledTimes(1); expect(config.refreshHierarchicalMemory).toHaveBeenCalledTimes(1); expect( - config.getGeminiClient().refreshSystemInstruction, + config.getLlmClient().refreshSystemInstruction, ).toHaveBeenCalledTimes(1); expect( vi.mocked(rebuildManagedAutoMemoryIndex).mock.invocationCallOrder[0], @@ -338,7 +338,7 @@ describe('managed memory refresh helper', () => { expect(config.refreshHierarchicalMemory).toHaveBeenCalledTimes(1); expect( - config.getGeminiClient().refreshSystemInstruction, + config.getLlmClient().refreshSystemInstruction, ).toHaveBeenCalledTimes(1); }); @@ -352,7 +352,7 @@ describe('managed memory refresh helper', () => { expect(config.refreshHierarchicalMemory).toHaveBeenCalledTimes(1); expect( - config.getGeminiClient().refreshSystemInstruction, + config.getLlmClient().refreshSystemInstruction, ).toHaveBeenCalledTimes(1); }); diff --git a/packages/core/src/memory/refresh.ts b/packages/core/src/memory/refresh.ts index 568c395e71a..dcebb9c0d87 100644 --- a/packages/core/src/memory/refresh.ts +++ b/packages/core/src/memory/refresh.ts @@ -157,7 +157,7 @@ export async function refreshMemoryInstruction( } try { - await config.getGeminiClient()?.refreshSystemInstruction(); + await config.getLlmClient()?.refreshSystemInstruction(); } catch (err) { debugLogger.warn( `${logPrefix(options)}refreshSystemInstruction failed: ${err}`, diff --git a/packages/core/src/memory/relevanceSelector.ts b/packages/core/src/memory/relevanceSelector.ts index a89b5e7a2e9..23290f9f719 100644 --- a/packages/core/src/memory/relevanceSelector.ts +++ b/packages/core/src/memory/relevanceSelector.ts @@ -122,7 +122,7 @@ export async function selectRelevantAutoMemoryDocumentsByModel( contents, schema: RESPONSE_SCHEMA, skipOutputLanguagePreference: true, - // Caller (`GeminiClient.MemoryPrefetchHandle`) owns lifecycle and aborts + // Caller (`LlmClient.MemoryPrefetchHandle`) owns lifecycle and aborts // via its controller on cleanup paths. The 30 s ceiling is a generous // safety net that only fires if the model API hangs (network partition, // server stall, runaway retry) AND the caller never aborts. Normal diff --git a/packages/core/src/permissions/classifier-transcript.test.ts b/packages/core/src/permissions/classifier-transcript.test.ts index ad894e5a18c..705025e3cac 100644 --- a/packages/core/src/permissions/classifier-transcript.test.ts +++ b/packages/core/src/permissions/classifier-transcript.test.ts @@ -323,7 +323,7 @@ describe('buildClassifierContents', () => { // can overflow the fast model's context window, fail-close the // classifier, and trigger denialTracking. The constant is exported // so scheduler + Session can request exactly this slice from - // GeminiClient.getHistoryTail — verify the truncation actually fires + // LlmClient.getHistoryTail — verify the truncation actually fires // when the input exceeds the window. it('exports MAX_TRANSCRIPT_MESSAGES so callers can size getHistoryTail correctly', () => { diff --git a/packages/core/src/services/backgroundShellRegistry.test.ts b/packages/core/src/services/backgroundShellRegistry.test.ts index 2c6fe32df0b..98ee01569ce 100644 --- a/packages/core/src/services/backgroundShellRegistry.test.ts +++ b/packages/core/src/services/backgroundShellRegistry.test.ts @@ -289,7 +289,7 @@ describe('BackgroundShellRegistry', () => { }); it('setNotificationCallback(undefined) clears the callback', () => { - // useGeminiStream's cleanup relies on this contract to avoid + // useLlmStream's cleanup relies on this contract to avoid // leaked callbacks firing into torn-down React state on unmount. // If a future refactor breaks the clearing path, stale callbacks // would fire silently — no test would catch it without this guard. diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index 9c9dec863ef..02aa39e9807 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -18,7 +18,7 @@ import type { Content } from '@google/genai'; import { CompressionStatus } from '../core/turn.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; import { tokenLimit } from '../core/tokenLimits.js'; -import type { GeminiChat } from '../core/geminiChat.js'; +import type { LlmChat } from '../core/llm-chat.js'; import type { Config } from '../config/config.js'; import { ApprovalMode } from '../config/config.js'; import type { @@ -39,7 +39,7 @@ vi.mock('../telemetry/loggers.js'); describe('ChatCompressionService', () => { let service: ChatCompressionService; - let mockChat: GeminiChat; + let mockChat: LlmChat; let mockConfig: Config; const mockPromptId = 'test-prompt-id'; let mockGetHookSystem: ReturnType; @@ -52,7 +52,7 @@ describe('ChatCompressionService', () => { mockChat.getHistory(curated), ), appendSystemInstruction: vi.fn(), - } as unknown as GeminiChat; + } as unknown as LlmChat; mockGetHookSystem = vi.fn().mockReturnValue({}); mockConfig = { getChatCompression: vi.fn(), @@ -2076,7 +2076,7 @@ describe('ChatCompressionService.compress sideQuery config', () => { const mockChat = { getHistory: getHistoryMock, getHistoryShallow: getHistoryMock, - } as unknown as GeminiChat; + } as unknown as LlmChat; const mockConfig = { getChatCompression: vi.fn(), getAutoCompactThreshold: vi.fn(), @@ -2141,7 +2141,7 @@ describe('ChatCompressionService.compress sideQuery config', () => { const mockChat = { getHistory: getHistoryMock, getHistoryShallow: getHistoryMock, - } as unknown as GeminiChat; + } as unknown as LlmChat; const warn = vi.fn(); const mockConfig = { getChatCompression: vi.fn(), @@ -2228,7 +2228,7 @@ describe('ChatCompressionService.compress cache sharing', () => { lastPromptTokenCountIsEstimated?: boolean; lastOutputTokenCount?: number; }): { - chat: GeminiChat; + chat: LlmChat; config: Config; generateText: ReturnType; } { @@ -2262,7 +2262,7 @@ describe('ChatCompressionService.compress cache sharing', () => { getLastOutputTokenCount: vi .fn() .mockReturnValue(options?.lastOutputTokenCount ?? 0), - } as unknown as GeminiChat; + } as unknown as LlmChat; const config = { getChatCompression: vi.fn(), getAutoCompactThreshold: vi.fn(), @@ -2964,7 +2964,7 @@ describe('ChatCompressionService.compress cheap-gate uses estimated tokens', () // mockChat/mockConfig rather than shared factories, so we follow that // pattern here. getHistory(true) returns a non-empty array so the cheap- // gate flow can reach the spy when the threshold is crossed. - function makeFakeChat(): GeminiChat { + function makeFakeChat(): LlmChat { const history: Content[] = [ { role: 'user', parts: [{ text: 'msg1' }] }, { role: 'model', parts: [{ text: 'msg2' }] }, @@ -2973,7 +2973,7 @@ describe('ChatCompressionService.compress cheap-gate uses estimated tokens', () return { getHistory: getHistoryMock, getHistoryShallow: getHistoryMock, - } as unknown as GeminiChat; + } as unknown as LlmChat; } function makeFakeConfig(opts: { contextWindowSize: number }): Config { @@ -3222,12 +3222,12 @@ describe('ChatCompressionService.compress — claude-code-style full-history com vi.restoreAllMocks(); }); - function makeFakeChat(history: Content[]): GeminiChat { + function makeFakeChat(history: Content[]): LlmChat { const getHistoryMock = vi.fn().mockReturnValue(history); return { getHistory: getHistoryMock, getHistoryShallow: getHistoryMock, - } as unknown as GeminiChat; + } as unknown as LlmChat; } function makeFakeConfig(): Config { @@ -3453,7 +3453,7 @@ describe('ChatCompressionService.compress cheap-gate uses computeThresholds.auto vi.restoreAllMocks(); }); - function makeFakeChat(): GeminiChat { + function makeFakeChat(): LlmChat { const history: Content[] = [ { role: 'user', parts: [{ text: 'msg1' }] }, { role: 'model', parts: [{ text: 'msg2' }] }, @@ -3462,7 +3462,7 @@ describe('ChatCompressionService.compress cheap-gate uses computeThresholds.auto return { getHistory: getHistoryMock, getHistoryShallow: getHistoryMock, - } as unknown as GeminiChat; + } as unknown as LlmChat; } function makeFakeConfig(opts: { contextWindowSize: number }): Config { @@ -3576,7 +3576,7 @@ describe('ChatCompressionService.compress cheap-gate runs against the full windo vi.restoreAllMocks(); }); - function makeFakeChat(): GeminiChat { + function makeFakeChat(): LlmChat { const history: Content[] = [ { role: 'user', parts: [{ text: 'msg1' }] }, { role: 'model', parts: [{ text: 'msg2' }] }, @@ -3585,7 +3585,7 @@ describe('ChatCompressionService.compress cheap-gate runs against the full windo return { getHistory: getHistoryMock, getHistoryShallow: getHistoryMock, - } as unknown as GeminiChat; + } as unknown as LlmChat; } function makeFakeConfig(opts: { contextWindowSize: number }): Config { @@ -3693,12 +3693,12 @@ describe('ChatCompressionService.compress — single-turn Node REPL image regres vi.restoreAllMocks(); }); - function makeFakeChat(history: Content[]): GeminiChat { + function makeFakeChat(history: Content[]): LlmChat { const getHistoryMock = vi.fn().mockReturnValue(history); return { getHistory: getHistoryMock, getHistoryShallow: getHistoryMock, - } as unknown as GeminiChat; + } as unknown as LlmChat; } function makeFakeConfig(): Config { @@ -3858,7 +3858,7 @@ describe('ChatCompressionService.compress — customInstructions plumbing', () = const mockChat = { getHistory: getHistoryMock, getHistoryShallow: getHistoryMock, - } as unknown as GeminiChat; + } as unknown as LlmChat; const hookSystem = opts.hookSystem ?? { firePreCompactEvent: vi.fn().mockResolvedValue(undefined), firePostCompactEvent: vi.fn().mockResolvedValue(undefined), @@ -3920,7 +3920,7 @@ describe('ChatCompressionService.compress — customInstructions plumbing', () = const mockChat = { getHistory: getHistoryMock, getHistoryShallow: getHistoryMock, - } as unknown as GeminiChat; + } as unknown as LlmChat; const mockConfig = { getChatCompression: vi.fn(), getAutoCompactThreshold: vi.fn(), @@ -4144,7 +4144,7 @@ describe('ChatCompressionService.compress — plan-mode + subagent attachment wi const mockChat = { getHistory: getHistoryMock, getHistoryShallow: getHistoryMock, - } as unknown as GeminiChat; + } as unknown as LlmChat; const mockConfig = { getChatCompression: vi.fn(), getAutoCompactThreshold: vi.fn(), @@ -4338,7 +4338,7 @@ describe('ChatCompressionService.compress — plan-mode + subagent attachment wi const mockChat = { getHistory: getHistoryMock, getHistoryShallow: getHistoryMock, - } as unknown as GeminiChat; + } as unknown as LlmChat; const mockConfig = { getChatCompression: vi.fn(), getAutoCompactThreshold: vi.fn(), @@ -4439,7 +4439,7 @@ const WINDOW = 65_536; describe('issue #7960: compression side-query output budget vs small windows', () => { let service: ChatCompressionService; - let mockChat: GeminiChat; + let mockChat: LlmChat; let mockConfig: Config; let capturedPromptTokens: number | undefined; let capturedMaxOutputTokens: number | undefined; @@ -4455,7 +4455,7 @@ describe('issue #7960: compression side-query output budget vs small windows', ( getHistoryShallow: vi.fn((curated?: boolean) => mockChat.getHistory(curated), ), - } as unknown as GeminiChat; + } as unknown as LlmChat; mockConfig = { getChatCompression: vi.fn(), getAutoCompactThreshold: vi.fn(), diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index cb5063872a7..d27a9b667b1 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -9,7 +9,7 @@ import type { Config } from '../config/config.js'; import { ApprovalMode } from '../config/config.js'; import type { GenerateTextResult } from '../core/baseLlmClient.js'; import { AuthType } from '../core/contentGenerator.js'; -import type { GeminiChat } from '../core/geminiChat.js'; +import type { LlmChat } from '../core/llm-chat.js'; import { type ChatCompressionInfo, type CompactionTriggerReason, @@ -137,7 +137,7 @@ export const HARD_BUFFER = 3_000; * Auto-compaction consecutive-failure circuit breaker. After this many * consecutive failures the cheap-gate NOOPs until a successful force * compress resets the counter. Co-located here with other compaction- - * tuning constants; the counter state itself lives on GeminiChat. + * tuning constants; the counter state itself lives on LlmChat. */ export const MAX_CONSECUTIVE_FAILURES = 3; @@ -390,7 +390,7 @@ function hasStateSnapshot(summary: string): boolean { export class ChatCompressionService { async compress( - chat: GeminiChat, + chat: LlmChat, opts: CompressOptions, ): Promise<{ newHistory: Content[] | null; info: ChatCompressionInfo }> { const { diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index e971609081d..59ba5929537 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -7,14 +7,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; import type { - ServerGeminiContentEvent, - ServerGeminiModelFallbackEvent, - ServerGeminiRetryEvent, - ServerGeminiStreamEvent, - ServerGeminiThoughtEvent, - ServerGeminiToolCallRequestEvent, + ServerLlmContentEvent, + ServerLlmModelFallbackEvent, + ServerLlmRetryEvent, + ServerLlmStreamEvent, + ServerLlmThoughtEvent, + ServerLlmToolCallRequestEvent, } from '../core/turn.js'; -import { GeminiEventType } from '../core/turn.js'; +import { LlmEventType } from '../core/turn.js'; import * as loggers from '../telemetry/loggers.js'; import { LoopType } from '../telemetry/types.js'; import type { DebugLogger } from '../utils/debugLogger.js'; @@ -74,8 +74,8 @@ describe('LoopDetectionService', () => { const createToolCallRequestEvent = ( name: string, args: Record, - ): ServerGeminiToolCallRequestEvent => ({ - type: GeminiEventType.ToolCallRequest, + ): ServerLlmToolCallRequestEvent => ({ + type: LlmEventType.ToolCallRequest, value: { name, args, @@ -85,16 +85,16 @@ describe('LoopDetectionService', () => { }, }); - const createContentEvent = (content: string): ServerGeminiContentEvent => ({ - type: GeminiEventType.Content, + const createContentEvent = (content: string): ServerLlmContentEvent => ({ + type: LlmEventType.Content, value: content, }); const createThoughtEvent = ( subject: string, description = '', - ): ServerGeminiThoughtEvent => ({ - type: GeminiEventType.Thought, + ): ServerLlmThoughtEvent => ({ + type: LlmEventType.Thought, value: { subject, description }, }); @@ -157,8 +157,8 @@ describe('LoopDetectionService', () => { param: 'value', }); const otherEvent = { - type: GeminiEventType.UserCancelled, - } as unknown as ServerGeminiStreamEvent; + type: LlmEventType.UserCancelled, + } as unknown as ServerLlmStreamEvent; // Send events just below the threshold for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD - 1; i++) { @@ -181,8 +181,8 @@ describe('LoopDetectionService', () => { expect( service.checkAlwaysOnSafeties({ - type: GeminiEventType.Retry, - } as ServerGeminiStreamEvent), + type: LlmEventType.Retry, + } as ServerLlmStreamEvent), ).toBe(false); for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD - 1; i++) { @@ -376,8 +376,8 @@ describe('LoopDetectionService', () => { expect( service.checkAlwaysOnSafeties({ - type: GeminiEventType.Retry, - } as ServerGeminiStreamEvent), + type: LlmEventType.Retry, + } as ServerLlmStreamEvent), ).toBe(false); for (const command of variants) { @@ -1111,7 +1111,7 @@ describe('LoopDetectionService', () => { it('should return false for unhandled event types', () => { const otherEvent = { type: 'unhandled_event', - } as unknown as ServerGeminiStreamEvent; + } as unknown as ServerLlmStreamEvent; expect(service.addAndCheck(otherEvent)).toBe(false); expect(service.addAndCheck(otherEvent)).toBe(false); }); @@ -1543,13 +1543,13 @@ describe('LoopDetectionService', () => { const createRetryEvent = ( isContinuation?: boolean, - ): ServerGeminiRetryEvent => ({ - type: GeminiEventType.Retry, + ): ServerLlmRetryEvent => ({ + type: LlmEventType.Retry, ...(isContinuation !== undefined && { isContinuation }), }); - const createModelFallbackEvent = (): ServerGeminiModelFallbackEvent => ({ - type: GeminiEventType.ModelFallback, + const createModelFallbackEvent = (): ServerLlmModelFallbackEvent => ({ + type: LlmEventType.ModelFallback, fromModel: 'primary-model', toModel: 'fallback-model', fallbackIndex: 1, @@ -1739,7 +1739,7 @@ describe('LoopDetectionService', () => { streamed += piece.length; if ( service.addAndCheck({ - type: GeminiEventType.Content, + type: LlmEventType.Content, value: piece, }) ) { @@ -1769,7 +1769,7 @@ describe('LoopDetectionService', () => { const piece = text.slice(i, i + DELTA); streamed += piece.length; expect( - service.addAndCheck({ type: GeminiEventType.Content, value: piece }), + service.addAndCheck({ type: LlmEventType.Content, value: piece }), ).toBe(false); if (streamed === insideSlackBand) { // Past the window, inside the slack band: no physical trim yet — @@ -2140,12 +2140,12 @@ describe('LoopDetectionService', () => { }); const retryEvent = { - type: GeminiEventType.Retry, - } as ServerGeminiStreamEvent; + type: LlmEventType.Retry, + } as ServerLlmStreamEvent; const finishedEvent = { - type: GeminiEventType.Finished, + type: LlmEventType.Finished, value: { reason: 'STOP' }, - } as unknown as ServerGeminiStreamEvent; + } as unknown as ServerLlmStreamEvent; it('does not fire at or below the soft cap', () => { service.reset(''); @@ -2580,7 +2580,7 @@ describe('LoopDetectionService', () => { it('does not count a retried replay toward the global-duplicate threshold', () => { service.reset(''); const stuck = createToolCallRequestEvent('stuck_tool', { param: 'same' }); - const retry = { type: GeminiEventType.Retry } as ServerGeminiStreamEvent; + const retry = { type: LlmEventType.Retry } as ServerLlmStreamEvent; // Failed attempt streams (threshold - 3) identical calls, then retries. for (let i = 0; i < GLOBAL_DUPLICATE_THRESHOLD - 3; i++) { expect(service.addAndCheckHeuristicLoops(stuck)).toBe(false); diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index c416f6f2a81..3f1173387db 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -5,8 +5,8 @@ */ import { createHash } from 'node:crypto'; -import type { ServerGeminiStreamEvent } from '../core/turn.js'; -import { GeminiEventType } from '../core/turn.js'; +import type { ServerLlmStreamEvent } from '../core/turn.js'; +import { LlmEventType } from '../core/turn.js'; import type { ThoughtSummary } from '../utils/thoughtUtils.js'; import { logLoopDetected, @@ -304,7 +304,7 @@ export class LoopDetectionService { * @param event - The stream event to process * @returns true if any tier detects a loop, false otherwise */ - addAndCheck(event: ServerGeminiStreamEvent): boolean { + addAndCheck(event: ServerLlmStreamEvent): boolean { if (this.checkAlwaysOnSafeties(event)) { return true; } @@ -312,13 +312,13 @@ export class LoopDetectionService { return this.addAndCheckHeuristicLoops(event); } - addAndCheckHeuristicLoops(event: ServerGeminiStreamEvent): boolean { + addAndCheckHeuristicLoops(event: ServerLlmStreamEvent): boolean { if (this.loopDetected || this.disabledForSession) { return this.loopDetected; } switch (event.type) { - case GeminiEventType.ToolCallRequest: { + case LlmEventType.ToolCallRequest: { // content chanting only happens in one single stream, reset if there // is a tool call in between this.resetContentTracking(); @@ -338,7 +338,7 @@ export class LoopDetectionService { globalDup || alternating || readFileLoop || actionStagnation; break; } - case GeminiEventType.Retry: { + case LlmEventType.Retry: { // A retry replays the failed attempt's tool calls (Turn clears // pendingToolCalls on retry), so drop the heuristic duplicate counters // to avoid firing on a duplicated replay — e.g. 3 identical calls + @@ -365,7 +365,7 @@ export class LoopDetectionService { } break; } - case GeminiEventType.ModelFallback: { + case LlmEventType.ModelFallback: { // The fallback model restarts the attempt from scratch: Turn clears // pending tool calls and stream consumers discard the failed model's // buffer, so the failed model's streamed content/thought text and @@ -378,11 +378,11 @@ export class LoopDetectionService { this.thoughtHistory = []; break; } - case GeminiEventType.Content: { + case LlmEventType.Content: { this.loopDetected = this.checkContentLoop(event.value); break; } - case GeminiEventType.Thought: { + case LlmEventType.Thought: { this.trackThought(event.value); this.loopDetected = this.checkRepetitiveThoughts(); if (!this.loopDetected) { @@ -419,7 +419,7 @@ export class LoopDetectionService { * explicit in-session disable; the cap is additionally tunable via the * `model.maxToolCallsPerTurn` setting. */ - checkAlwaysOnSafeties(event: ServerGeminiStreamEvent): boolean { + checkAlwaysOnSafeties(event: ServerLlmStreamEvent): boolean { if (this.loopDetected) { return true; } @@ -428,7 +428,7 @@ export class LoopDetectionService { // count as the rollback floor. The per-turn total accumulates across // ToolResult continuations, so the floor must track the last committed // round-trip rather than resetting to zero. - if (event.type === GeminiEventType.Finished) { + if (event.type === LlmEventType.Finished) { this.turnToolCallTotalCommitted = this.turnToolCallTotal; return false; } @@ -441,7 +441,7 @@ export class LoopDetectionService { // cleared (consistent with how the heuristic path clears // globalToolCallCounts on retry): the replayed calls re-populate it, and a // stuck pattern simply re-accumulates toward the threshold. - if (event.type === GeminiEventType.Retry) { + if (event.type === LlmEventType.Retry) { this.turnToolCallTotal = this.turnToolCallTotalCommitted; this.resetToolCallCount(); this.capKeyCounts.clear(); @@ -449,7 +449,7 @@ export class LoopDetectionService { return false; } - if (event.type !== GeminiEventType.ToolCallRequest) { + if (event.type !== LlmEventType.ToolCallRequest) { return false; } diff --git a/packages/core/src/services/memoryDiagnosticsDumper.test.ts b/packages/core/src/services/memoryDiagnosticsDumper.test.ts index d2601e9ba0c..b7dce1210ae 100644 --- a/packages/core/src/services/memoryDiagnosticsDumper.test.ts +++ b/packages/core/src/services/memoryDiagnosticsDumper.test.ts @@ -46,7 +46,7 @@ function createMockConfig(overrides: Partial> = {}) { return { getSessionId: vi.fn().mockReturnValue('test-session-id-12345678'), getCliVersion: vi.fn().mockReturnValue('0.17.0'), - getGeminiClient: vi.fn().mockReturnValue({ + getLlmClient: vi.fn().mockReturnValue({ getChat: () => ({ getHistoryLength: () => 500, }), @@ -232,9 +232,9 @@ describe('MemoryDiagnosticsDumper', () => { expect(results[3]).toBeUndefined(); }); - it('handles missing geminiClient gracefully', async () => { + it('handles missing llmClient gracefully', async () => { const config = createMockConfig({ - getGeminiClient: vi.fn().mockReturnValue(null), + getLlmClient: vi.fn().mockReturnValue(null), }); const dumper = new MemoryDiagnosticsDumper(config); diff --git a/packages/core/src/services/memoryDiagnosticsDumper.ts b/packages/core/src/services/memoryDiagnosticsDumper.ts index dd723dbf2ae..d0ff3fd9fae 100644 --- a/packages/core/src/services/memoryDiagnosticsDumper.ts +++ b/packages/core/src/services/memoryDiagnosticsDumper.ts @@ -175,9 +175,9 @@ export class MemoryDiagnosticsDumper { private collectSessionStats(): Record { try { - const geminiClient = this.config.getGeminiClient?.(); - if (!geminiClient) return { available: false }; - const historyLength = geminiClient.getChat?.()?.getHistoryLength?.() ?? 0; + const llmClient = this.config.getLlmClient?.(); + if (!llmClient) return { available: false }; + const historyLength = llmClient.getChat?.()?.getHistoryLength?.() ?? 0; return { historyEntries: historyLength, }; diff --git a/packages/core/src/services/memoryPressureMonitor.test.ts b/packages/core/src/services/memoryPressureMonitor.test.ts index 901a287c87a..af5e0d9a944 100644 --- a/packages/core/src/services/memoryPressureMonitor.test.ts +++ b/packages/core/src/services/memoryPressureMonitor.test.ts @@ -137,7 +137,7 @@ beforeAll(async () => { function createMockConfig( overrides: { fileReadCache?: Partial; - geminiClient?: { + llmClient?: { isInitialized?: () => boolean; getChat?: () => { getHistoryShallow?: () => unknown[]; @@ -153,7 +153,7 @@ function createMockConfig( } = {}, ): Config { const client = - overrides.geminiClient === undefined + overrides.llmClient === undefined ? { isInitialized: () => true, getChat: () => ({ @@ -162,7 +162,7 @@ function createMockConfig( setHistory: vi.fn(), }), } - : overrides.geminiClient; + : overrides.llmClient; return { getProjectRoot: () => '/mock/project', getTargetDir: () => '/mock/project', @@ -172,7 +172,7 @@ function createMockConfig( evictNotAccessedSince: vi.fn().mockReturnValue(0), ...overrides.fileReadCache, }) as unknown as FileReadCache, - getGeminiClient: () => client as never, + getLlmClient: () => client as never, getClearContextOnIdle: () => ({ clearContextMinutes: 60, toolResultsNumToKeep: 5, @@ -1248,7 +1248,7 @@ describe('MemoryPressureMonitor', () => { const setHistory = vi.fn(); const monitor = new MemoryPressureMonitor( createMockConfig({ - geminiClient: { + llmClient: { isInitialized: () => false, getChat: () => ({ getHistoryShallow: () => [{ role: 'user' }], @@ -1272,7 +1272,7 @@ describe('MemoryPressureMonitor', () => { const originalHistory = [{ role: 'user', parts: [{ text: 'hello' }] }]; const monitor = new MemoryPressureMonitor( createMockConfig({ - geminiClient: { + llmClient: { isInitialized: () => true, getChat: () => ({ getHistoryShallow: () => originalHistory, @@ -1296,7 +1296,7 @@ describe('MemoryPressureMonitor', () => { const setHistory = vi.fn(); const monitor = new MemoryPressureMonitor( createMockConfig({ - geminiClient: { + llmClient: { isInitialized: () => true, getChat: () => ({ getHistoryShallow: () => [], @@ -1318,7 +1318,7 @@ describe('MemoryPressureMonitor', () => { it('handles exceptions during compaction gracefully', async () => { const monitor = new MemoryPressureMonitor( createMockConfig({ - geminiClient: { + llmClient: { isInitialized: () => true, getChat: () => { throw new Error('chat unavailable'); @@ -1337,11 +1337,11 @@ describe('MemoryPressureMonitor', () => { expect(monitor.getConsecutiveFailures()).toBe(0); }); - it('handles getGeminiClient returning null', async () => { + it('handles getLlmClient returning null', async () => { const setHistory = vi.fn(); const monitor = new MemoryPressureMonitor( createMockConfig({ - geminiClient: null, + llmClient: null, }), { ...DEFAULT_PRESSURE_CONFIG, cleanupCooldownMs: 0 }, ); @@ -1392,7 +1392,7 @@ describe('MemoryPressureMonitor', () => { } const monitor = new MemoryPressureMonitor( createMockConfig({ - geminiClient: { + llmClient: { isInitialized: () => true, getChat: () => ({ getHistoryShallow: () => toolHistory, @@ -1469,7 +1469,7 @@ describe('MemoryPressureMonitor', () => { } const monitor = new MemoryPressureMonitor( createMockConfig({ - geminiClient: { + llmClient: { isInitialized: () => true, getChat: () => ({ getHistoryShallow: () => toolHistory, @@ -1529,7 +1529,7 @@ describe('MemoryPressureMonitor', () => { } const monitor = new MemoryPressureMonitor( createMockConfig({ - geminiClient: { + llmClient: { isInitialized: () => true, getChat: () => ({ getHistoryShallow: () => toolHistory, @@ -1589,7 +1589,7 @@ describe('MemoryPressureMonitor', () => { } const monitor = new MemoryPressureMonitor( createMockConfig({ - geminiClient: { + llmClient: { isInitialized: () => true, getChat: () => ({ getHistoryShallow: () => toolHistory, diff --git a/packages/core/src/services/memoryPressureMonitor.ts b/packages/core/src/services/memoryPressureMonitor.ts index e4c01779523..393cf2f3f6a 100644 --- a/packages/core/src/services/memoryPressureMonitor.ts +++ b/packages/core/src/services/memoryPressureMonitor.ts @@ -707,7 +707,7 @@ export class MemoryPressureMonitor extends EventEmitter { } case 'compact_history': { try { - const client = this.coreConfig.getGeminiClient?.(); + const client = this.coreConfig.getLlmClient?.(); if (!client?.isInitialized?.()) { debugLogger.debug( '[COMPACT_HISTORY] skipped: client not initialized', diff --git a/packages/core/src/services/postCompactAttachments.test.ts b/packages/core/src/services/postCompactAttachments.test.ts index 76a23d01846..76d1acebd82 100644 --- a/packages/core/src/services/postCompactAttachments.test.ts +++ b/packages/core/src/services/postCompactAttachments.test.ts @@ -803,7 +803,7 @@ describe('composePostCompactHistory', () => { it('emits role-alternating history with multiple file/image attachments merged into a single user Content (Finding 2)', async () => { // Regression: prior implementation pushed each file restoration block // as its own user Content, producing consecutive user roles which - // violates geminiChat.test.ts:6289 strict-alternation assertion and + // violates llm-chat.test.ts:6289 strict-alternation assertion and // is rejected by Gemini API with "consecutive same-role content". const small = join(tmpDir, 'a.ts'); writeFileSync(small, 'export const a = 1;'); diff --git a/packages/core/src/services/postCompactAttachments.ts b/packages/core/src/services/postCompactAttachments.ts index bb5de3792ee..5cc4f7f7896 100644 --- a/packages/core/src/services/postCompactAttachments.ts +++ b/packages/core/src/services/postCompactAttachments.ts @@ -795,7 +795,7 @@ export async function composePostCompactHistory( // Merge every file restoration block AND the image block into a // single user Content (Finding 2). Pushing them as separate user // Contents produces consecutive same-role entries, which - // geminiChat.test.ts:6289 enforces against and which Gemini + // llm-chat.test.ts:6289 enforces against and which Gemini // providers reject as 400 "consecutive same-role content". // // Order within the merged user Content: diff --git a/packages/core/src/services/session-writer-lease.test.ts b/packages/core/src/services/session-writer-lease.test.ts index 684dffaf681..d90b2c62a82 100644 --- a/packages/core/src/services/session-writer-lease.test.ts +++ b/packages/core/src/services/session-writer-lease.test.ts @@ -501,7 +501,7 @@ describe('SessionWriterLease', () => { ); await config.initialize({ - skipGeminiInitialization: true, + skipLlmInitialization: true, skipHooks: true, skipMcpDiscovery: true, skipSkillManager: true, @@ -561,7 +561,7 @@ describe('SessionWriterLease', () => { ); const initialize = (config: Config) => config.initialize({ - skipGeminiInitialization: true, + skipLlmInitialization: true, skipHooks: true, skipMcpDiscovery: true, skipSkillManager: true, @@ -678,7 +678,7 @@ describe('SessionWriterLease', () => { ); await config.initialize({ - skipGeminiInitialization: true, + skipLlmInitialization: true, skipHooks: true, skipMcpDiscovery: true, skipSkillManager: true, diff --git a/packages/core/src/services/sessionRecap.test.ts b/packages/core/src/services/sessionRecap.test.ts index 10ee64aee92..8c2586a99f7 100644 --- a/packages/core/src/services/sessionRecap.test.ts +++ b/packages/core/src/services/sessionRecap.test.ts @@ -50,7 +50,7 @@ describe('generateSessionRecap', () => { const config = { getFastModel: vi.fn(() => 'qwen-turbo'), getModel: vi.fn(() => 'qwen-plus'), - getGeminiClient: vi.fn(() => ({ + getLlmClient: vi.fn(() => ({ getHistoryShallow: () => history, })), getBaseLlmClient: vi.fn(() => ({ generateText })), diff --git a/packages/core/src/services/sessionRecap.ts b/packages/core/src/services/sessionRecap.ts index 12d776b8c33..5f0a65cd836 100644 --- a/packages/core/src/services/sessionRecap.ts +++ b/packages/core/src/services/sessionRecap.ts @@ -48,13 +48,13 @@ export async function generateSessionRecap( abortSignal: AbortSignal, ): Promise { try { - const geminiClient = config.getGeminiClient(); - if (!geminiClient) { - debugLogger.debug('recap skipped: no geminiClient available'); + const llmClient = config.getLlmClient(); + if (!llmClient) { + debugLogger.debug('recap skipped: no llmClient available'); return null; } - const fullHistory = geminiClient.getHistoryShallow(); + const fullHistory = llmClient.getHistoryShallow(); if (fullHistory.length < 2) { debugLogger.debug( `recap skipped: history too short (${fullHistory.length} messages)`, diff --git a/packages/core/src/services/sessionTitle.test.ts b/packages/core/src/services/sessionTitle.test.ts index a97fb8bafb5..ebd0a0df2f3 100644 --- a/packages/core/src/services/sessionTitle.test.ts +++ b/packages/core/src/services/sessionTitle.test.ts @@ -39,7 +39,7 @@ function makeConfig(opts: MockOptions): { const config = { getFastModel: vi.fn(() => opts.fastModel ?? undefined), getModel: vi.fn(() => 'qwen-plus'), - getGeminiClient: vi.fn(() => ({ + getLlmClient: vi.fn(() => ({ getHistoryShallow: () => opts.history ?? [], getChat: () => ({ getHistory: () => opts.history ?? [], @@ -457,7 +457,7 @@ describe('tryGenerateSessionTitle', () => { const config = { getFastModel: vi.fn(() => 'qwen-turbo'), getModel: vi.fn(() => 'qwen-plus'), - getGeminiClient: vi.fn(() => ({ + getLlmClient: vi.fn(() => ({ getHistoryShallow: () => history, getChat: () => ({ getHistory: () => history, @@ -639,7 +639,7 @@ describe('tryGenerateSessionTitle', () => { const config = { getFastModel: vi.fn(() => 'qwen-turbo'), getModel: vi.fn(() => 'qwen-plus'), - getGeminiClient: vi.fn(() => ({ + getLlmClient: vi.fn(() => ({ getHistoryShallow: () => history, getChat: () => ({ getHistory: () => history, diff --git a/packages/core/src/services/sessionTitle.ts b/packages/core/src/services/sessionTitle.ts index e1d7fcba127..3289a21d0e1 100644 --- a/packages/core/src/services/sessionTitle.ts +++ b/packages/core/src/services/sessionTitle.ts @@ -89,7 +89,7 @@ const TRAILING_PAIRED_BRACKETS_RE = * * - `no_fast_model`: config.getFastModel() returned undefined. * User needs to configure one via `/model --fast `. - * - `no_client`: BaseLlmClient or GeminiClient not yet initialized. Rare, + * - `no_client`: BaseLlmClient or LlmClient not yet initialized. Rare, * usually means the session hasn't authenticated yet. * - `empty_history`: the conversation has fewer than 2 turns of usable text. * User should send at least one message before asking for a title. @@ -129,10 +129,10 @@ export async function tryGenerateSessionTitle( const model = config.getFastModel(); if (!model) return { ok: false, reason: 'no_fast_model' }; - const geminiClient = config.getGeminiClient(); - if (!geminiClient) return { ok: false, reason: 'no_client' }; + const llmClient = config.getLlmClient(); + if (!llmClient) return { ok: false, reason: 'no_client' }; - const fullHistory = geminiClient.getHistoryShallow(); + const fullHistory = llmClient.getHistoryShallow(); if (fullHistory.length < 2) return { ok: false, reason: 'empty_history' }; const hasDisplayProjection = userDisplayTexts.some( diff --git a/packages/core/src/services/toolUseSummary.test.ts b/packages/core/src/services/toolUseSummary.test.ts index 69d77311a78..756a5c5e85c 100644 --- a/packages/core/src/services/toolUseSummary.test.ts +++ b/packages/core/src/services/toolUseSummary.test.ts @@ -190,7 +190,7 @@ describe('generateToolUseSummary', () => { getFastModel: () => fastModel, // The chat-client check inside generateToolUseSummary is a gating // sanity check that runs before any LLM call; only its presence matters. - getGeminiClient: () => (baseLlm ? {} : undefined), + getLlmClient: () => (baseLlm ? {} : undefined), getBaseLlmClient: () => baseLlm, getModel: () => fastModel ?? 'main-model', } as unknown as Config; diff --git a/packages/core/src/services/toolUseSummary.ts b/packages/core/src/services/toolUseSummary.ts index f26a8534729..e9f050b5756 100644 --- a/packages/core/src/services/toolUseSummary.ts +++ b/packages/core/src/services/toolUseSummary.ts @@ -130,7 +130,7 @@ export async function generateToolUseSummary( const userPrompt = `${contextPrefix}Tools completed:\n\n${toolSummaries}\n\nLabel:`; - if (!config.getGeminiClient()) { + if (!config.getLlmClient()) { debugLogger.debug('No gemini client available — skipping'); return null; } diff --git a/packages/core/src/skills/skill-manager.test.ts b/packages/core/src/skills/skill-manager.test.ts index d267cafe32d..8247b3d3de2 100644 --- a/packages/core/src/skills/skill-manager.test.ts +++ b/packages/core/src/skills/skill-manager.test.ts @@ -1513,7 +1513,7 @@ Body. // Regression for /review: when a single tool call yields multiple // candidate paths (e.g. ripGrep `paths: [a, b, c]`), the per-path // listener fire was triggering N successive SkillTool.refreshSkills / - // geminiClient.setTools() round-trips. The batch API should fire + // llmClient.setTools() round-trips. The batch API should fire // listeners once with the union of activations. vi.mocked(fs.readdir).mockResolvedValue([ { diff --git a/packages/core/src/telemetry/constants.ts b/packages/core/src/telemetry/constants.ts index 6e0ab72d3af..f8bad02eb8f 100644 --- a/packages/core/src/telemetry/constants.ts +++ b/packages/core/src/telemetry/constants.ts @@ -39,7 +39,7 @@ export const EVENT_PROTOCOL_TAG_SANITIZED = 'qwen-code.chat.protocol_tag_sanitized'; // Phase 4b — HTTP-status retry telemetry emitted by `retryWithBackoff` for // 429 / 5xx errors at LLM call sites. Distinct from EVENT_CONTENT_RETRY, -// which is fired by geminiChat for InvalidStreamError retries on a separate +// which is fired by llmChat for InvalidStreamError retries on a separate // retry budget. See docs/design/telemetry-llm-request-timing-design.md. export const EVENT_API_RETRY = 'qwen-code.api_retry'; export const EVENT_CONVERSATION_FINISHED = 'qwen-code.conversation_finished'; diff --git a/packages/core/src/telemetry/detailed-span-attributes.ts b/packages/core/src/telemetry/detailed-span-attributes.ts index 0b58f45e98b..352e1b08ed1 100644 --- a/packages/core/src/telemetry/detailed-span-attributes.ts +++ b/packages/core/src/telemetry/detailed-span-attributes.ts @@ -9,7 +9,7 @@ import type { Config } from '../config/config.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { isTelemetrySdkInitialized } from './sdk.js'; import { DEFAULT_SENSITIVE_SPAN_ATTRIBUTE_MAX_LENGTH } from './constants.js'; -import { extractGeminiContent, stringifyGenAiJson } from './gen-ai-content.js'; +import { extractLlmContent, stringifyGenAiJson } from './gen-ai-content.js'; const SHORT_TRUNCATION_SUFFIX = '...[TRUNCATED]'; const debugLogger = createDebugLogger('GEN_AI_CONTENT'); @@ -276,7 +276,7 @@ export function addSystemPromptAttributes( systemInstruction: unknown, ): void { if (!areSensitiveSpanAttributesEnabled(config)) return; - const parts = extractGeminiContent({ + const parts = extractLlmContent({ config: { systemInstruction }, }).systemInstructions; if (parts !== undefined) { @@ -301,7 +301,7 @@ export function addToolSchemaAttributes( ) ? tools : [{ functionDeclarations: tools }]; - const definitions = extractGeminiContent({ + const definitions = extractLlmContent({ config: { tools: providerTools }, }).toolDefinitions; if (definitions !== undefined) { diff --git a/packages/core/src/telemetry/gen-ai-content.test.ts b/packages/core/src/telemetry/gen-ai-content.test.ts index 7438b2eef6e..d84d1266d9d 100644 --- a/packages/core/src/telemetry/gen-ai-content.test.ts +++ b/packages/core/src/telemetry/gen-ai-content.test.ts @@ -9,7 +9,7 @@ import { readFileSync } from 'node:fs'; import { Ajv } from 'ajv'; import { extractAnthropicContent, - extractGeminiContent, + extractLlmContent, extractOpenAiContent, GenAiOutputAccumulator, stringifyGenAiJson, @@ -251,7 +251,7 @@ describe('GenAI content conversion', () => { }); it('converts Gemini media and lowercases JSON Schema types', () => { - const content = extractGeminiContent({ + const content = extractLlmContent({ contents: [ { role: 'model', @@ -317,7 +317,7 @@ describe('GenAI content conversion', () => { it('omits only invalid optional parameters but rejects missing identity', () => { expect( - extractGeminiContent({ + extractLlmContent({ config: { tools: [ { @@ -366,7 +366,7 @@ describe('GenAI content conversion', () => { it('preserves boolean Draft-07 tool parameter schemas', () => { expect( - extractGeminiContent({ + extractLlmContent({ config: { tools: [ { @@ -702,7 +702,7 @@ describe('GenAI output accumulation', () => { it('uses error for unfinished candidates only on failure', () => { const failed = new GenAiOutputAccumulator(true, 10_000); - failed.recordGeminiResponse({ + failed.recordLlmResponse({ candidates: [ { index: 0, content: { role: 'model', parts: [{ text: 'a' }] } }, ], @@ -711,7 +711,7 @@ describe('GenAI output accumulation', () => { expect(failed.finishReasons).toEqual(['error']); const successful = new GenAiOutputAccumulator(true, 10_000); - successful.recordGeminiResponse({ + successful.recordLlmResponse({ candidates: [ { index: 0, content: { role: 'model', parts: [{ text: 'a' }] } }, ], @@ -739,7 +739,7 @@ describe('GenAI output accumulation', () => { [ new GenAiOutputAccumulator(false, 10_000), (accumulator) => - accumulator.recordGeminiResponse({ + accumulator.recordLlmResponse({ candidates: [{ index: 0, content: { role: 'model', parts: [] } }], }), ], @@ -754,7 +754,7 @@ describe('GenAI output accumulation', () => { it('accumulates Gemini text chunks without replacing earlier content', () => { const output = new GenAiOutputAccumulator(true, 10_000); - output.recordGeminiChunk({ + output.recordLlmChunk({ candidates: [ { index: 0, @@ -765,7 +765,7 @@ describe('GenAI output accumulation', () => { }, ], }); - output.recordGeminiChunk({ + output.recordLlmChunk({ candidates: [ { index: 0, @@ -773,7 +773,7 @@ describe('GenAI output accumulation', () => { }, ], }); - output.recordGeminiChunk({ + output.recordLlmChunk({ candidates: [ { index: 0, @@ -833,7 +833,7 @@ describe('GenAI output accumulation', () => { expect(output.finalize(true)).toBeUndefined(); const missing = new GenAiOutputAccumulator(true, 10_000); - missing.recordGeminiResponse({}); + missing.recordLlmResponse({}); expect(missing.finalize(false)).toBeUndefined(); }); @@ -880,11 +880,11 @@ describe('GenAI output accumulation', () => { ], }; const expected = new GenAiOutputAccumulator(true, 10_000); - expected.recordGeminiChunk(chunk); + expected.recordLlmChunk(chunk); const serialized = expected.finalize(true)!; const exact = new GenAiOutputAccumulator(true, serialized.length); - exact.recordGeminiChunk(chunk); + exact.recordLlmChunk(chunk); expect(exact.finalize(true)).toBe(serialized); }); }); diff --git a/packages/core/src/telemetry/gen-ai-content.ts b/packages/core/src/telemetry/gen-ai-content.ts index 75b44ab7557..4d08eddad40 100644 --- a/packages/core/src/telemetry/gen-ai-content.ts +++ b/packages/core/src/telemetry/gen-ai-content.ts @@ -874,7 +874,7 @@ function anthropicTools(value: unknown): JsonObject[] | undefined { return result; } -function geminiTools(value: unknown): JsonObject[] | undefined { +function llmTools(value: unknown): JsonObject[] | undefined { if (!Array.isArray(value)) return undefined; const result: JsonObject[] = []; for (const wrapper of value) { @@ -933,7 +933,7 @@ export function extractAnthropicContent( }; } -export function extractGeminiContent(request: object): GenAiContentAttributes { +export function extractLlmContent(request: object): GenAiContentAttributes { const value = request as Record; const config = record(value['config']); return { @@ -948,7 +948,7 @@ export function extractGeminiContent(request: object): GenAiContentAttributes { : undefined, toolDefinitions: config && Object.hasOwn(config, 'tools') - ? geminiTools(config['tools']) + ? llmTools(config['tools']) : undefined, }; } @@ -1275,7 +1275,7 @@ export class GenAiOutputAccumulator { } } - recordGeminiResponse(response: object): void { + recordLlmResponse(response: object): void { const candidates = (response as Record)['candidates']; if (!Array.isArray(candidates)) return; this.observedResponse = true; @@ -1307,7 +1307,7 @@ export class GenAiOutputAccumulator { } } - recordGeminiChunk(chunk: object): void { + recordLlmChunk(chunk: object): void { const candidates = (chunk as Record)['candidates']; if (!Array.isArray(candidates)) return; this.observedResponse = true; diff --git a/packages/core/src/telemetry/gen-ai-request.test.ts b/packages/core/src/telemetry/gen-ai-request.test.ts index 54ee77a0262..f38db23add9 100644 --- a/packages/core/src/telemetry/gen-ai-request.test.ts +++ b/packages/core/src/telemetry/gen-ai-request.test.ts @@ -9,7 +9,7 @@ import { ROOT_CONTEXT, type Attributes, type Span } from '@opentelemetry/api'; import { createGenAiRequestObserverContext, extractAnthropicRequestAttributes, - extractGeminiRequestAttributes, + extractLlmRequestAttributes, extractOpenAiRequestAttributes, reportOpenAiRequest, } from './gen-ai-request.js'; @@ -144,7 +144,7 @@ describe('GenAI request attribute extraction', () => { it('extracts fields from the final Gemini config', () => { expect( - extractGeminiRequestAttributes({ + extractLlmRequestAttributes({ candidateCount: 99, config: { candidateCount: 2, @@ -168,8 +168,8 @@ describe('GenAI request attribute extraction', () => { }); it('omits invalid Gemini config shapes', () => { - expect(extractGeminiRequestAttributes({ config: null })).toEqual({}); - expect(extractGeminiRequestAttributes({ config: 'invalid' })).toEqual({}); + expect(extractLlmRequestAttributes({ config: null })).toEqual({}); + expect(extractLlmRequestAttributes({ config: 'invalid' })).toEqual({}); }); }); diff --git a/packages/core/src/telemetry/gen-ai-request.ts b/packages/core/src/telemetry/gen-ai-request.ts index 571c44be618..b6284ef94de 100644 --- a/packages/core/src/telemetry/gen-ai-request.ts +++ b/packages/core/src/telemetry/gen-ai-request.ts @@ -14,7 +14,7 @@ import { } from '@opentelemetry/api'; import { extractAnthropicContent, - extractGeminiContent, + extractLlmContent, extractOpenAiContent, GenAiOutputAccumulator, stringifyGenAiJson, @@ -160,7 +160,7 @@ export function extractAnthropicRequestAttributes(request: object): Attributes { return attributes; } -export function extractGeminiRequestAttributes(request: object): Attributes { +export function extractLlmRequestAttributes(request: object): Attributes { const record = request as RequestRecord; const config = ownValue(record, 'config'); if (typeof config !== 'object' || config === null) return {}; @@ -503,14 +503,14 @@ export function reportAnthropicFollowingRequest( ); } -export function reportGeminiRequest( +export function reportLlmRequest( request: object, requestContext?: Context, ): GenAiAttemptHandle | undefined { return reportRequest( request, - extractGeminiRequestAttributes, - extractGeminiContent, + extractLlmRequestAttributes, + extractLlmContent, requestContext, ); } @@ -551,20 +551,18 @@ export function reportAnthropicEvent( ); } -export function reportGeminiResponse( +export function reportLlmResponse( handle: GenAiAttemptHandle | undefined, response: object, ): void { handle?.controller.record(handle, (output) => - output.recordGeminiResponse(response), + output.recordLlmResponse(response), ); } -export function reportGeminiChunk( +export function reportLlmChunk( handle: GenAiAttemptHandle | undefined, chunk: object, ): void { - handle?.controller.record(handle, (output) => - output.recordGeminiChunk(chunk), - ); + handle?.controller.record(handle, (output) => output.recordLlmChunk(chunk)); } diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 452b3d56593..45197f0b144 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -16,7 +16,7 @@ import type { } from '../index.js'; import { AuthType, - GeminiClient, + LlmClient, ToolConfirmationOutcome, ToolErrorType, ToolRegistry, @@ -1216,7 +1216,7 @@ describe('loggers', () => { const cfg1 = { getSessionId: () => 'test-session-id', getTargetDir: () => 'target-dir', - getGeminiClient: () => mockGeminiClient, + getLlmClient: () => mockLlmClient, } as Config; const cfg2 = { getSessionId: () => 'test-session-id', @@ -1246,11 +1246,11 @@ describe('loggers', () => { getUserMemory: () => 'user-memory', } as unknown as Config; - const mockGeminiClient = new GeminiClient(cfg2); + const mockLlmClient = new LlmClient(cfg2); const mockConfig = { getSessionId: () => 'test-session-id', getTargetDir: () => 'target-dir', - getGeminiClient: () => mockGeminiClient, + getLlmClient: () => mockLlmClient, getUsageStatisticsEnabled: () => true, getTelemetryEnabled: () => true, getTelemetryLogPromptsEnabled: () => true, diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index c2e226d9c0e..ba869338512 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -968,7 +968,7 @@ export function logContentRetryFailure( /** * Phase 4b — Emits an HTTP-status retry event fired from `retryWithBackoff` * at an LLM call site (via the `onRetry` callback opt-in). Distinct from - * `logContentRetry`, which is fired by `geminiChat`'s content-recovery loop. + * `logContentRetry`, which is fired by `llmChat`'s content-recovery loop. * * Fan-out (sink 0 fires first, before the SDK guard, so retries are counted * even with telemetry off; sinks 1–3 match the `logContentRetry` shape): diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts index d43e34b02d9..678b16bedd4 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts @@ -1005,7 +1005,7 @@ export class QwenLogger { } // Phase 4b — HTTP-status retry from retryWithBackoff (429/5xx). Distinct from - // logContentRetryEvent which is fired by geminiChat's content-recovery loop. + // logContentRetryEvent which is fired by llmChat's content-recovery loop. logApiRetryEvent(event: ApiRetryEvent): void { const rumEvent = this.createActionEvent('misc', 'api_retry', { properties: { diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index fe1801eb351..b662acb37fd 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -209,7 +209,7 @@ export class ToolCallEvent implements BaseTelemetryEvent { // placeholder constant so consumers still see the call happened — // duration, success, decision metrics are preserved — but the // payload itself doesn't ride along. The same constant is used by - // `redactStructuredOutputArgsForRecording` in `core/geminiChat.ts` + // `redactStructuredOutputArgsForRecording` in `core/llm-chat.ts` // for the on-disk JSONL surface so neither side can silently drift. this.function_args = call.request.name === ToolNames.STRUCTURED_OUTPUT @@ -838,7 +838,7 @@ export class ProtocolTagSanitizedEvent implements BaseTelemetryEvent { * Phase 4b — HTTP-status retry telemetry. Emitted by `retryWithBackoff` (via * the `onRetry` callback opt-in) for HTTP 429 / 5xx retries at LLM call sites. * - * Distinct from {@link ContentRetryEvent}, which is emitted by `geminiChat`'s + * Distinct from {@link ContentRetryEvent}, which is emitted by `llmChat`'s * for-loop for `InvalidStreamError` retries that use * `INVALID_STREAM_RETRY_CONFIG`, not `retryWithBackoff`. A single user prompt * may fire BOTH event types; sum across event types to count total retries per diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index 08f4fa38823..6df5ae89178 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -206,7 +206,7 @@ describe('AgentTool', () => { getSessionId: vi.fn().mockReturnValue('test-session-id'), getCliVersion: vi.fn().mockReturnValue('test-version'), getSubagentManager: vi.fn(), - getGeminiClient: vi.fn().mockReturnValue(undefined), + getLlmClient: vi.fn().mockReturnValue(undefined), getHookSystem: vi.fn().mockReturnValue(undefined), getStopHookBlockingCap: vi.fn().mockReturnValue(8), getTranscriptPath: vi.fn().mockReturnValue('/test/transcript'), @@ -3731,12 +3731,12 @@ describe('AgentTool', () => { // Parent conversation history: empty (first-turn fork — falls back to // the fork agent's own systemPrompt + wildcard tools because no // cache params have been captured yet). - vi.mocked(config.getGeminiClient).mockReturnValue({ + vi.mocked(config.getLlmClient).mockReturnValue({ getHistory: vi.fn().mockReturnValue([]), getChat: vi.fn().mockReturnValue({ getGenerationConfig: vi.fn().mockReturnValue({}), }), - } as unknown as ReturnType); + } as unknown as ReturnType); vi.mocked(AgentHeadless.create).mockClear(); vi.mocked(AgentHeadless.create).mockResolvedValue(mockAgent); @@ -4137,7 +4137,7 @@ describe('AgentTool', () => { role: 'model' as const, parts: [{ text: 'second answer' }], }; - vi.mocked(config.getGeminiClient).mockReturnValue({ + vi.mocked(config.getLlmClient).mockReturnValue({ getHistoryShallow: vi .fn() .mockReturnValue([ @@ -4153,7 +4153,7 @@ describe('AgentTool', () => { getChat: vi.fn().mockReturnValue({ getGenerationConfig: vi.fn().mockReturnValue({}), }), - } as unknown as ReturnType); + } as unknown as ReturnType); const invocation = ( agentTool as AgentToolWithProtectedMethods @@ -4208,12 +4208,12 @@ describe('AgentTool', () => { secondUser, secondModel, ]); - vi.mocked(config.getGeminiClient).mockReturnValue({ + vi.mocked(config.getLlmClient).mockReturnValue({ getHistoryShallow, getChat: vi.fn().mockReturnValue({ getGenerationConfig: vi.fn().mockReturnValue({}), }), - } as unknown as ReturnType); + } as unknown as ReturnType); const invocation = ( agentTool as AgentToolWithProtectedMethods @@ -4283,12 +4283,12 @@ describe('AgentTool', () => { const getHistoryShallow = vi .fn() .mockReturnValue([startup, firstUser, forkLaunch]); - vi.mocked(config.getGeminiClient).mockReturnValue({ + vi.mocked(config.getLlmClient).mockReturnValue({ getHistoryShallow, getChat: vi.fn().mockReturnValue({ getGenerationConfig: vi.fn().mockReturnValue({}), }), - } as unknown as ReturnType); + } as unknown as ReturnType); const invocation = ( agentTool as AgentToolWithProtectedMethods @@ -4359,7 +4359,7 @@ describe('AgentTool', () => { secondUser, secondModel, ]); - vi.mocked(config.getGeminiClient).mockReturnValue({ + vi.mocked(config.getLlmClient).mockReturnValue({ // getHistoryShallow() (no arg) supplies the startup context; // getHistoryForForkWindow is intentionally omitted to exercise the // uncurated getHistory() fallback for the bounded window. @@ -4376,7 +4376,7 @@ describe('AgentTool', () => { getChat: vi.fn().mockReturnValue({ getGenerationConfig: vi.fn().mockReturnValue({}), }), - } as unknown as ReturnType); + } as unknown as ReturnType); const invocation = ( agentTool as AgentToolWithProtectedMethods @@ -4448,7 +4448,7 @@ describe('AgentTool', () => { parameters: { type: 'object', properties: {} }, }, ]; - vi.mocked(config.getGeminiClient).mockReturnValue({ + vi.mocked(config.getLlmClient).mockReturnValue({ getHistory: vi.fn().mockReturnValue([]), getChat: vi.fn().mockReturnValue({ getGenerationConfig: vi.fn().mockReturnValue({ @@ -4456,7 +4456,7 @@ describe('AgentTool', () => { tools: [{ functionDeclarations: parentToolDecls }], }), }), - } as unknown as ReturnType); + } as unknown as ReturnType); const invocation = ( agentTool as AgentToolWithProtectedMethods @@ -4503,7 +4503,7 @@ describe('AgentTool', () => { parameters: { type: 'object', properties: {} }, }, ]; - vi.mocked(config.getGeminiClient).mockReturnValue({ + vi.mocked(config.getLlmClient).mockReturnValue({ getHistory: vi.fn().mockReturnValue([]), getChat: vi.fn().mockReturnValue({ getGenerationConfig: vi.fn().mockReturnValue({ @@ -4511,7 +4511,7 @@ describe('AgentTool', () => { tools: [{ functionDeclarations: parentToolDecls }], }), }), - } as unknown as ReturnType); + } as unknown as ReturnType); const invocation = ( agentTool as AgentToolWithProtectedMethods @@ -4591,7 +4591,7 @@ describe('AgentTool', () => { (mockAgent as unknown as Record)[ 'setExternalMessageWaitPredicate' ] = vi.fn(); - vi.mocked(config.getGeminiClient).mockReturnValue({ + vi.mocked(config.getLlmClient).mockReturnValue({ getHistory: vi.fn().mockReturnValue([ { role: 'user', @@ -4602,7 +4602,7 @@ describe('AgentTool', () => { getChat: vi.fn().mockReturnValue({ getGenerationConfig: vi.fn().mockReturnValue({}), }), - } as unknown as ReturnType); + } as unknown as ReturnType); const stubRegistry = ( config as unknown as { getBackgroundTaskRegistry: () => { @@ -5005,7 +5005,7 @@ describe('AgentTool', () => { fireSubagentStopEvent: vi.fn().mockResolvedValue(undefined), } as unknown as HookSystem; - vi.mocked(config.getGeminiClient).mockReturnValue(undefined as never); + vi.mocked(config.getLlmClient).mockReturnValue(undefined as never); (config as unknown as Record)['getHookSystem'] = vi .fn() .mockReturnValue(mockHookSystem); @@ -5201,7 +5201,7 @@ describe('AgentTool', () => { fireSubagentStopEvent: vi.fn().mockResolvedValue(undefined), } as unknown as HookSystem; - vi.mocked(config.getGeminiClient).mockReturnValue(undefined as never); + vi.mocked(config.getLlmClient).mockReturnValue(undefined as never); (config as unknown as Record)['getHookSystem'] = vi .fn() .mockReturnValue(mockHookSystem); @@ -7388,7 +7388,7 @@ describe('AgentTool', () => { { functionDeclarations: [{ name: 'Bash' }, { name: 'Read' }] }, ], }; - const geminiClient = { + const llmClient = { getHistory: vi .fn() .mockReturnValue([{ role: 'model', parts: [{ text: 'Ready' }] }]), @@ -7396,8 +7396,8 @@ describe('AgentTool', () => { getGenerationConfig: () => generationConfig, }), }; - vi.mocked(config.getGeminiClient).mockReturnValue( - geminiClient as unknown as ReturnType, + vi.mocked(config.getLlmClient).mockReturnValue( + llmClient as unknown as ReturnType, ); const attachSpy = vi.spyOn(transcript, 'attachJsonlTranscriptWriter'); diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index d00a4bdaf2e..e657e14feae 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -887,9 +887,9 @@ export class AgentTool extends BaseDeclarativeTool { this.updateDescriptionAndSchema(); } finally { // Update the client with the new tools - const geminiClient = this.config.getGeminiClient(); - if (geminiClient) { - await geminiClient.setTools(); + const llmClient = this.config.getLlmClient(); + if (llmClient) { + await llmClient.setTools(); } } } @@ -1726,8 +1726,8 @@ class AgentToolInvocation extends BaseToolInvocation { taskPrompt: string; toolConfig: ToolConfig; }> { - const geminiClient = this.config.getGeminiClient(); - const generationConfig = geminiClient?.getChat().getGenerationConfig(); + const llmClient = this.config.getLlmClient(); + const generationConfig = llmClient?.getChat().getGenerationConfig(); const parentToolNames = generationConfig?.systemInstruction ? extractParentToolNames(generationConfig) : []; @@ -1743,7 +1743,7 @@ class AgentToolInvocation extends BaseToolInvocation { ); const profilePromptHint = this.forkProfile?.promptHint; let rawHistory: Content[] = []; - if (geminiClient) { + if (llmClient) { // The `all` and numeric paths curate history differently on purpose. // `all` takes curated history directly. The numeric path reads // *uncurated* history so the startup context can be sliced off on its own @@ -1754,13 +1754,12 @@ class AgentToolInvocation extends BaseToolInvocation { // reminder into the first turn and break bounded selection. if (forkTurns === 'all') { rawHistory = selectForkHistory( - geminiClient.getHistoryShallow?.(true) ?? - geminiClient.getHistory(true), + llmClient.getHistoryShallow?.(true) ?? llmClient.getHistory(true), forkTurns, ); } else { const comprehensiveHistory = - geminiClient.getHistoryShallow?.() ?? geminiClient.getHistory(); + llmClient.getHistoryShallow?.() ?? llmClient.getHistory(); const startupContext = comprehensiveHistory.slice( 0, getStartupContextLength(comprehensiveHistory), @@ -1776,8 +1775,7 @@ class AgentToolInvocation extends BaseToolInvocation { // startupContext above prepends it again, duplicating startup. // Uncurated history keeps the startup reminder as its own pure // entry, which selectForkHistory strips cleanly. - geminiClient.getHistoryForForkWindow?.() ?? - geminiClient.getHistory(), + llmClient.getHistoryForForkWindow?.() ?? llmClient.getHistory(), forkTurns, ), ]; diff --git a/packages/core/src/tools/agent/fork-subagent.ts b/packages/core/src/tools/agent/fork-subagent.ts index c5fd1aa056c..04efe24eda8 100644 --- a/packages/core/src/tools/agent/fork-subagent.ts +++ b/packages/core/src/tools/agent/fork-subagent.ts @@ -48,7 +48,7 @@ export const FORK_DEFAULT_MAX_TURNS = 200; // reads the marker and rejects nested fork calls. // // Why ALS and not a history scan: the nested AgentTool's `this.config` is the -// main process Config, so `getGeminiClient().getHistory()` returns the parent +// main process Config, so `getLlmClient().getHistory()` returns the parent // conversation — not the fork child's chat — and cannot be used to detect // nesting. Async context propagation works naturally across the fork's // await chain and is scoped per-execution. diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index 26e35ee93a5..03abac585b6 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -37,7 +37,7 @@ describe('EditTool', () => { let tempDir: string; let rootDir: string; let mockConfig: Config; - let geminiClient: any; + let llmClient: any; let baseLlmClient: any; let fileReadCache: FileReadCache; let mockFileHistoryService: { trackEdit: ReturnType }; @@ -52,7 +52,7 @@ describe('EditTool', () => { mockFileHistoryService = { trackEdit: vi.fn() }; fsService = new StandardFileSystemService(); - geminiClient = { + llmClient = { generateJson: mockGenerateJson, // mockGenerateJson is already defined and hoisted }; @@ -61,7 +61,7 @@ describe('EditTool', () => { }; mockConfig = { - getGeminiClient: vi.fn().mockReturnValue(geminiClient), + getLlmClient: vi.fn().mockReturnValue(llmClient), getBaseLlmClient: vi.fn().mockReturnValue(baseLlmClient), getTargetDir: () => rootDir, getProjectRoot: () => rootDir, diff --git a/packages/core/src/tools/enterPlanMode.ts b/packages/core/src/tools/enterPlanMode.ts index 45e79c1d1f2..7b0208c404f 100644 --- a/packages/core/src/tools/enterPlanMode.ts +++ b/packages/core/src/tools/enterPlanMode.ts @@ -159,10 +159,10 @@ class EnterPlanModeToolInvocation extends BaseToolInvocation< const revealedBefore = registry.isDeferredToolRevealed(exitPlanModeName); if (!revealedBefore) { registry.revealDeferredTool(exitPlanModeName); - const geminiClient = this.config.getGeminiClient(); - if (geminiClient) { + const llmClient = this.config.getLlmClient(); + if (llmClient) { try { - await geminiClient.setTools(); + await llmClient.setTools(); } catch (setErr) { // Rollback the reveal on setTools failure so the registry // stays consistent with the chat's declaration list. diff --git a/packages/core/src/tools/notebook-edit.test.ts b/packages/core/src/tools/notebook-edit.test.ts index 880a3a6814b..3d896110203 100644 --- a/packages/core/src/tools/notebook-edit.test.ts +++ b/packages/core/src/tools/notebook-edit.test.ts @@ -49,7 +49,7 @@ describe('NotebookEditTool', () => { getFileReadCache: () => fileReadCache, getFileHistoryService: () => mockFileHistoryService, getFileReadCacheDisabled: () => false, - getGeminiClient: vi.fn(), + getLlmClient: vi.fn(), getBaseLlmClient: vi.fn(), getIdeMode: () => false, getApiKey: () => 'test-api-key', diff --git a/packages/core/src/tools/shell.backgroundStatus.test.ts b/packages/core/src/tools/shell.backgroundStatus.test.ts index 4e2444bc5bd..a7dfe8651f4 100644 --- a/packages/core/src/tools/shell.backgroundStatus.test.ts +++ b/packages/core/src/tools/shell.backgroundStatus.test.ts @@ -97,7 +97,7 @@ describe('background shell status sidecar (integration, real spawn)', () => { getTruncateToolOutputLines: vi.fn().mockReturnValue(0), isTruncateToolOutputThresholdExplicit: vi.fn().mockReturnValue(false), getPermissionManager: vi.fn().mockReturnValue(undefined), - getGeminiClient: vi.fn(), + getLlmClient: vi.fn(), getFileSystemService: vi.fn().mockReturnValue(undefined), getFileHistoryService: vi.fn().mockReturnValue(undefined), getFileReadCache: vi.fn().mockReturnValue(undefined), diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 62c119d7880..c8a13e7625d 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -137,7 +137,7 @@ describe('ShellTool', () => { getTruncateToolOutputLines: vi.fn().mockReturnValue(0), isTruncateToolOutputThresholdExplicit: vi.fn().mockReturnValue(false), getPermissionManager: vi.fn().mockReturnValue(undefined), - getGeminiClient: vi.fn(), + getLlmClient: vi.fn(), getFileSystemService: vi.fn().mockReturnValue(mockFileSystemService), getFileHistoryService: vi.fn().mockReturnValue(mockFileHistoryService), getFileReadCache: vi.fn().mockReturnValue(mockFileReadCache), diff --git a/packages/core/src/tools/skill.test.ts b/packages/core/src/tools/skill.test.ts index 420d9326bb5..ded6a7322e0 100644 --- a/packages/core/src/tools/skill.test.ts +++ b/packages/core/src/tools/skill.test.ts @@ -96,7 +96,7 @@ describe('SkillTool', () => { getAutoSkillEnabled: vi.fn().mockReturnValue(true), getSessionId: vi.fn().mockReturnValue('test-session-id'), getSkillManager: vi.fn(), - getGeminiClient: vi.fn().mockReturnValue(undefined), + getLlmClient: vi.fn().mockReturnValue(undefined), getModelInvocableCommandsProvider: vi.fn().mockReturnValue(null), getModelInvocableCommandsExecutor: vi.fn().mockReturnValue(null), getPermissionManager: vi diff --git a/packages/core/src/tools/skill.ts b/packages/core/src/tools/skill.ts index 58d426a7828..3261c551b72 100644 --- a/packages/core/src/tools/skill.ts +++ b/packages/core/src/tools/skill.ts @@ -161,7 +161,7 @@ export class SkillTool extends BaseDeclarativeTool { * toggle, or MCP-prompt provider change). * * It deliberately does NOT mutate the tool declaration or call - * `geminiClient.setTools()`. The Skill tool's description is static + * `llmClient.setTools()`. The Skill tool's description is static * (`SKILL_TOOL_DESCRIPTION`), so the skill set no longer affects the tools * block — and the tools block is the front of the tools → system → messages * prompt-cache prefix, where any byte change invalidates the whole cached diff --git a/packages/core/src/tools/syntheticOutput.ts b/packages/core/src/tools/syntheticOutput.ts index 2763098bc5a..cfe26978fc6 100644 --- a/packages/core/src/tools/syntheticOutput.ts +++ b/packages/core/src/tools/syntheticOutput.ts @@ -25,7 +25,7 @@ export type StructuredOutputParams = Record; * 1. `ToolCallEvent` in `telemetry/types.ts` — keeps the payload out * of OTLP exports / QwenLogger / ui-telemetry stream / chat-recording * UI event mirror. - * 2. `redactStructuredOutputArgsForRecording` in `core/geminiChat.ts` + * 2. `redactStructuredOutputArgsForRecording` in `core/llm-chat.ts` * — keeps the payload out of the on-disk chat-recording JSONL * (which gets re-fed into model context on `--continue` / * `--resume`). diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index aeccbba72ee..d81f413a7e6 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -811,7 +811,7 @@ export class ToolRegistry { } /** - * Clears the set of revealed deferred tools. Called by {@link GeminiClient} + * Clears the set of revealed deferred tools. Called by {@link LlmClient} * when a chat session is reset (e.g. `/clear`) so the new session starts * with no ToolSearch-discovered reveals — the same state as any fresh * session. Session-setup reveals pinned via {@link pinDeferredToolReveal} diff --git a/packages/core/src/tools/tool-search.test.ts b/packages/core/src/tools/tool-search.test.ts index 0b8bac60430..647cae0c534 100644 --- a/packages/core/src/tools/tool-search.test.ts +++ b/packages/core/src/tools/tool-search.test.ts @@ -41,8 +41,8 @@ function makeConfigWithRegistry(): { const registry = new ToolRegistry(config); vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry); // Stub out the chat client reference so ToolSearch can sync newly - // revealed tools via setTools() without a real GeminiClient. - vi.spyOn(config, 'getGeminiClient').mockReturnValue({ + // revealed tools via setTools() without a real LlmClient. + vi.spyOn(config, 'getLlmClient').mockReturnValue({ setTools: vi.fn().mockResolvedValue(undefined), } as never); return { config, registry }; @@ -540,13 +540,13 @@ describe('ToolSearchTool', () => { // the revealedDeferred set (which is meant to track on-demand // reveals only) and must not trigger setTools(): the tool is // already in the chat's declaration list. Triggering setTools() - // here also risks a spurious "GeminiClient not initialised" + // here also risks a spurious "LlmClient not initialised" // failure when the inspection happens before init completes. registry.registerTool( new MockTool({ name: 'core_tool', shouldDefer: false }), ); const setToolsSpy = vi.fn().mockResolvedValue(undefined); - vi.spyOn(config, 'getGeminiClient').mockReturnValue({ + vi.spyOn(config, 'getLlmClient').mockReturnValue({ setTools: setToolsSpy, } as never); @@ -576,7 +576,7 @@ describe('ToolSearchTool', () => { }), ); const setToolsSpy = vi.fn().mockResolvedValue(undefined); - vi.spyOn(config, 'getGeminiClient').mockReturnValue({ + vi.spyOn(config, 'getLlmClient').mockReturnValue({ setTools: setToolsSpy, } as never); @@ -599,7 +599,7 @@ describe('ToolSearchTool', () => { }), ); const setToolsSpy = vi.fn().mockResolvedValue(undefined); - vi.spyOn(config, 'getGeminiClient').mockReturnValue({ + vi.spyOn(config, 'getLlmClient').mockReturnValue({ setTools: setToolsSpy, } as never); @@ -643,7 +643,7 @@ describe('ToolSearchTool', () => { }), ); const setToolsSpy = vi.fn().mockResolvedValue(undefined); - vi.spyOn(config, 'getGeminiClient').mockReturnValue({ + vi.spyOn(config, 'getLlmClient').mockReturnValue({ setTools: setToolsSpy, } as never); @@ -864,7 +864,7 @@ describe('ToolSearchTool', () => { shouldDefer: true, }), ); - vi.spyOn(config, 'getGeminiClient').mockReturnValue({ + vi.spyOn(config, 'getLlmClient').mockReturnValue({ setTools: vi.fn().mockRejectedValue(new Error('chat not initialised')), } as never); @@ -901,7 +901,7 @@ describe('ToolSearchTool', () => { // reveals, not pre-existing ones. registry.revealDeferredTool('cron_list'); - vi.spyOn(config, 'getGeminiClient').mockReturnValue({ + vi.spyOn(config, 'getLlmClient').mockReturnValue({ setTools: vi.fn().mockRejectedValue(new Error('chat not initialised')), } as never); @@ -948,7 +948,7 @@ describe('ToolSearchTool', () => { expect(registry.isDeferredToolRevealed('bravo')).toBe(false); }); - it('treats a null GeminiClient identically to setTools() throwing', async () => { + it('treats a null LlmClient identically to setTools() throwing', async () => { // Without the explicit null-check, optional chaining (`?.setTools()`) // silently no-ops if init hasn't completed yet, leaving the reveal // in the registry while the API never received the schema. The @@ -957,8 +957,8 @@ describe('ToolSearchTool', () => { registry.registerTool( new MockTool({ name: 'cron_create', shouldDefer: true }), ); - vi.spyOn(config, 'getGeminiClient').mockReturnValue( - null as unknown as ReturnType, + vi.spyOn(config, 'getLlmClient').mockReturnValue( + null as unknown as ReturnType, ); const tool = new ToolSearchTool(config); @@ -967,7 +967,7 @@ describe('ToolSearchTool', () => { .execute(new AbortController().signal); expect(result.error).toBeDefined(); - expect(result.error?.message).toContain('GeminiClient not initialised'); + expect(result.error?.message).toContain('LlmClient not initialised'); expect(String(result.llmContent)).not.toContain('"name":"cron_create"'); // Reveal rolled back so subsequent ToolSearch can find the tool. expect(registry.isDeferredToolRevealed('cron_create')).toBe(false); @@ -995,7 +995,7 @@ describe('ToolSearchTool', () => { ); vi.spyOn(visibleConfig, 'getToolRegistry').mockReturnValue(visibleRegistry); - vi.spyOn(visibleConfig, 'getGeminiClient').mockReturnValue({ + vi.spyOn(visibleConfig, 'getLlmClient').mockReturnValue({ setTools: vi.fn().mockResolvedValue(undefined), refreshStartupContextReminder: vi.fn().mockResolvedValue(undefined), } as never); @@ -1023,7 +1023,7 @@ describe('ToolSearchTool', () => { vi.spyOn(visibleConfig, 'getToolRegistry').mockReturnValue(visibleRegistry); const mockSetTools = vi.fn().mockResolvedValue(undefined); - vi.spyOn(visibleConfig, 'getGeminiClient').mockReturnValue({ + vi.spyOn(visibleConfig, 'getLlmClient').mockReturnValue({ setTools: mockSetTools, refreshStartupContextReminder: vi.fn().mockResolvedValue(undefined), } as never); @@ -1072,7 +1072,7 @@ describe('ToolSearchTool', () => { vi.spyOn(visibleConfig, 'getToolRegistry').mockReturnValue(visibleRegistry); const mockSetTools = vi.fn().mockResolvedValue(undefined); - vi.spyOn(visibleConfig, 'getGeminiClient').mockReturnValue({ + vi.spyOn(visibleConfig, 'getLlmClient').mockReturnValue({ setTools: mockSetTools, refreshStartupContextReminder: vi.fn().mockResolvedValue(undefined), } as never); diff --git a/packages/core/src/tools/tool-search.ts b/packages/core/src/tools/tool-search.ts index e923b4ab876..083d1817190 100644 --- a/packages/core/src/tools/tool-search.ts +++ b/packages/core/src/tools/tool-search.ts @@ -325,7 +325,7 @@ class ToolSearchInvocation extends BaseToolInvocation< // / alwaysLoad tools (the model may use it to re-inspect a schema) // — those don't need reveal (they're already in the declaration // list) and pulling them through setTools() would risk a spurious - // "GeminiClient not initialised" failure for what is just a + // "LlmClient not initialised" failure for what is just a // schema-inspection call. const isLoadable = registry.isDeferredAndHidden(canonical); if (isLoadable) { @@ -345,17 +345,17 @@ class ToolSearchInvocation extends BaseToolInvocation< // what is just a schema-inspection request). let setToolsError: string | undefined; if (newlyRevealed.length > 0) { - const geminiClient = this.config.getGeminiClient(); - if (!geminiClient) { + const llmClient = this.config.getLlmClient(); + if (!llmClient) { // Optional chaining (`?.setTools()`) used to silently no-op here, // leaving the registry with reveals the API never received — // exactly the inconsistency `setTools() throws` already guards // against. Treat null client identically: rollback + surface an // error so the caller can retry once init is complete. - setToolsError = 'GeminiClient not initialised'; + setToolsError = 'LlmClient not initialised'; } else { try { - await geminiClient.setTools(); + await llmClient.setTools(); } catch (err) { setToolsError = err instanceof Error ? err.message : String(err); // Same rationale as ensureTool above: debugLogger.warn is diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index c33475dfb37..25959fe9163 100644 --- a/packages/core/src/tools/write-file.test.ts +++ b/packages/core/src/tools/write-file.test.ts @@ -29,7 +29,7 @@ import { clearAutoMemoryRootCache } from '../memory/paths.js'; import path from 'node:path'; import fs from 'node:fs'; import os from 'node:os'; -import { GeminiClient } from '../core/client.js'; +import { LlmClient } from '../core/client.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; import { FileReadCache } from '../services/fileReadCache.js'; import { StandardFileSystemService } from '../services/fileSystemService.js'; @@ -40,7 +40,7 @@ const rootDir = path.resolve(os.tmpdir(), 'qwen-code-test-root'); // --- MOCKS --- vi.mock('../core/client.js'); -let mockGeminiClientInstance: Mocked; +let mockLlmClientInstance: Mocked; // Mock Config const fsService = new StandardFileSystemService(); @@ -51,7 +51,7 @@ const mockConfigInternal = { getProjectRoot: () => rootDir, getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT), setApprovalMode: vi.fn(), - getGeminiClient: vi.fn(), // Initialize as a plain mock function + getLlmClient: vi.fn(), // Initialize as a plain mock function getBaseLlmClient: vi.fn(), // Initialize as a plain mock function getFileSystemService: () => fsService, getWorkspaceContext: () => createMockWorkspaceContext(rootDir), @@ -110,16 +110,14 @@ describe('WriteFileTool', () => { fs.mkdirSync(rootDir, { recursive: true }); } - // Setup GeminiClient mock - mockGeminiClientInstance = new (vi.mocked(GeminiClient))( + // Setup LlmClient mock + mockLlmClientInstance = new (vi.mocked(LlmClient))( mockConfig, - ) as Mocked; - vi.mocked(GeminiClient).mockImplementation(() => mockGeminiClientInstance); + ) as Mocked; + vi.mocked(LlmClient).mockImplementation(() => mockLlmClientInstance); - // Now that mockGeminiClientInstance is initialized, set the mock implementation for getGeminiClient - mockConfigInternal.getGeminiClient.mockReturnValue( - mockGeminiClientInstance, - ); + // Now that mockLlmClientInstance is initialized, set the mock implementation for getLlmClient + mockConfigInternal.getLlmClient.mockReturnValue(mockLlmClientInstance); tool = new WriteFileTool(mockConfig); diff --git a/packages/core/src/utils/btwUtils.ts b/packages/core/src/utils/btwUtils.ts index 798e92a99c4..5bd3c652aa2 100644 --- a/packages/core/src/utils/btwUtils.ts +++ b/packages/core/src/utils/btwUtils.ts @@ -33,13 +33,13 @@ export function buildBtwPrompt(question: string): string { export function buildBtwCacheSafeParams( config: Config, ): CacheSafeParams | null { - const geminiClient = config.getGeminiClient(); + const llmClient = config.getLlmClient(); try { - const chat = geminiClient.getChat(); + const chat = llmClient.getChat(); const generationConfig = chat.getGenerationConfig(); if (!generationConfig) return null; const maxHistoryEntries = 40; - const history = geminiClient.getHistoryTail(maxHistoryEntries, true); + const history = llmClient.getHistoryTail(maxHistoryEntries, true); return { generationConfig: structuredClone(generationConfig), history, diff --git a/packages/core/src/utils/nextSpeakerChecker.test.ts b/packages/core/src/utils/nextSpeakerChecker.test.ts index 89f7ea66a90..7606281d825 100644 --- a/packages/core/src/utils/nextSpeakerChecker.test.ts +++ b/packages/core/src/utils/nextSpeakerChecker.test.ts @@ -12,7 +12,7 @@ import type { ContentGenerator } from '../core/contentGenerator.js'; import type { Config } from '../config/config.js'; import type { NextSpeakerResponse } from './nextSpeakerChecker.js'; import { checkNextSpeaker } from './nextSpeakerChecker.js'; -import { GeminiChat } from '../core/geminiChat.js'; +import { LlmChat } from '../core/llm-chat.js'; // Mock fs module to prevent actual file system operations during tests const mockFileSystem = new Map(); @@ -41,12 +41,12 @@ vi.mock('node:fs', () => { }; }); -// Mock GeminiClient and Config constructor +// Mock LlmClient and Config constructor vi.mock('../core/baseLlmClient.js'); vi.mock('../config/config.js'); describe('checkNextSpeaker', () => { - let chatInstance: GeminiChat; + let chatInstance: LlmChat; let mockConfig: Config; let mockBaseLlmClient: BaseLlmClient; const abortSignal = new AbortController().signal; @@ -77,8 +77,8 @@ describe('checkNextSpeaker', () => { }, } as unknown as Config; - // GeminiChat will receive the mocked instances via the mocked GoogleGenAI constructor - chatInstance = new GeminiChat( + // LlmChat will receive the mocked instances via the mocked GoogleGenAI constructor + chatInstance = new LlmChat( mockConfig, {}, [], // initial history diff --git a/packages/core/src/utils/nextSpeakerChecker.ts b/packages/core/src/utils/nextSpeakerChecker.ts index 381f405bfae..f58553c4586 100644 --- a/packages/core/src/utils/nextSpeakerChecker.ts +++ b/packages/core/src/utils/nextSpeakerChecker.ts @@ -5,7 +5,7 @@ */ import type { Content } from '@google/genai'; -import type { GeminiChat } from '../core/geminiChat.js'; +import type { LlmChat } from '../core/llm-chat.js'; import { isFunctionResponse } from './messageInspectors.js'; import type { Config } from '../config/config.js'; import { createDebugLogger } from './debugLogger.js'; @@ -43,7 +43,7 @@ export interface NextSpeakerResponse { } export async function checkNextSpeaker( - chat: GeminiChat, + chat: LlmChat, config: Config, abortSignal: AbortSignal, promptId: string, @@ -106,7 +106,7 @@ export async function checkNextSpeaker( }); } catch (error) { debugLogger.warn( - 'Failed to talk to Gemini endpoint when seeing if conversation should continue.', + 'Failed to talk to the LLM endpoint when checking whether the conversation should continue.', error, ); return null; diff --git a/packages/core/src/utils/retry.test.ts b/packages/core/src/utils/retry.test.ts index c0ecbfa0ce3..a4a96f55062 100644 --- a/packages/core/src/utils/retry.test.ts +++ b/packages/core/src/utils/retry.test.ts @@ -735,7 +735,7 @@ describe('retryWithBackoff', () => { it('thrown error must not be a rate-limit error (pins no-status intent)', async () => { // The thrown error deliberately carries no .status so that // isRateLimitError() returns false — preventing the stream-side - // rate-limit retry loop in geminiChat.ts from re-driving it. + // rate-limit retry loop in llm-chat.ts from re-driving it. const quotaError = Object.assign( new Error( '429 Your token-plan 1-week quota has been exhausted. The quota will reset at 07-27 09:25:00 UTC.', diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index 5faabd51594..50ad6a5d509 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -385,7 +385,7 @@ export async function retryWithBackoff( ); // Intentionally throws a plain Error with no `.status`: a 429 status // would make isRateLimitError() return true and re-trigger the - // stream-side rate-limit retry loop in geminiChat.ts (up to 10 retries + // stream-side rate-limit retry loop in llm-chat.ts (up to 10 retries // at 1-5 min delays), reintroducing the silent hang this fast-fail // eliminates. This also skips model fallback — quota exhaustion is // provider-scoped and temporary, so the user should retry after the diff --git a/packages/core/src/utils/startupEventSink.ts b/packages/core/src/utils/startupEventSink.ts index b57f3530267..24664af44aa 100644 --- a/packages/core/src/utils/startupEventSink.ts +++ b/packages/core/src/utils/startupEventSink.ts @@ -8,7 +8,7 @@ * Cross-package sink for startup-time profiler events. * * The cli package owns the actual startup profiler (`packages/cli/src/utils/startupProfiler.ts`) - * but core-package code (config init, MCP discovery, GeminiClient.setTools, etc.) is + * but core-package code (config init, MCP discovery, LlmClient.setTools, etc.) is * the source of several first-screen / first-paint metrics. To avoid an * undesirable core → cli dependency, core code records events via this sink, * and the cli registers a real handler at startup. diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 7ea1dbd9aba..80208a18bb2 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -3234,7 +3234,7 @@ export class DaemonClient { * Generate a one-sentence "where did I leave off" * recap of the session. Wraps `generateSessionRecap` (core/services/ * sessionRecap.ts) via an ACP control-channel ext-method, so the - * summary is computed against the active GeminiClient chat history + * summary is computed against the active LlmClient chat history * inside the daemon's ACP child. * * Non-strict mutation gate — posture matches `/session/:id/prompt` diff --git a/packages/web-shell/client/hooks/useStreamingLoadingMetrics.ts b/packages/web-shell/client/hooks/useStreamingLoadingMetrics.ts index d6f7b55b16a..357852e87ba 100644 --- a/packages/web-shell/client/hooks/useStreamingLoadingMetrics.ts +++ b/packages/web-shell/client/hooks/useStreamingLoadingMetrics.ts @@ -18,7 +18,7 @@ interface BlocksScan { /** * CLI-aligned streaming loading metrics derived from transcript blocks. * - * CLI source (useGeminiStream.ts + LoadingIndicator.tsx): + * CLI source (use-llm-stream.ts + LoadingIndicator.tsx): * - streamingChars: accumulated from text_delta (+text.length) and * ToolCallRequest (+JSON.stringify(args).length). Reset only on new * user queries, NOT on tool-result continuations. diff --git a/scripts/check-serve-fast-path-bundle.js b/scripts/check-serve-fast-path-bundle.js index caebf514217..b6dfd06f8fb 100644 --- a/scripts/check-serve-fast-path-bundle.js +++ b/scripts/check-serve-fast-path-bundle.js @@ -62,10 +62,7 @@ const SDK_IMPL_ROOT = { const FORBIDDEN_SOURCE_INPUTS = [ { label: 'Gemini runtime', - suffixes: [ - 'packages/cli/src/gemini.tsx', - 'packages/cli/dist/src/gemini.js', - ], + suffixes: ['packages/cli/src/llm.tsx', 'packages/cli/dist/src/llm.js'], }, { label: 'ACP agent runtime', @@ -512,7 +509,7 @@ export function checkSdkImplProtocolBoundary({ * `cli.ts` bootstraps only when it is the main module, comparing * `import.meta.url` against `process.argv[1]`. The bundle is built with * `splitting: true`, so a *static* `import ... from './cli.js'` in any module - * the entry loads lazily (e.g. `gemini.tsx`) makes esbuild move the entry's + * the entry loads lazily (e.g. `llm.tsx`) makes esbuild move the entry's * body into a shared chunk and leave `dist/cli.js` as a re-export stub. Inside * a chunk that comparison can never hold, so the bundled CLI exits 0 without * running anything — with `tsc`, eslint and every src-based unit test still diff --git a/scripts/tests/serve-fast-path-bundle-check.test.js b/scripts/tests/serve-fast-path-bundle-check.test.js index 2553ab32cf6..be53dcff8e7 100644 --- a/scripts/tests/serve-fast-path-bundle-check.test.js +++ b/scripts/tests/serve-fast-path-bundle-check.test.js @@ -133,7 +133,7 @@ describe('serve fast-path bundle check', () => { 'dist/chunks/run-qwen-serve.js': output({ inputs: [ 'packages/cli/src/serve/run-qwen-serve.ts', - 'packages/cli/src/gemini.tsx', + 'packages/cli/src/llm.tsx', 'packages/cli/src/acp-integration/acpAgent.ts', ], }), From 4842cf751d1d636ce3af70fd3b5b0ba4826ffdbd Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 26 Aug 2026 19:44:41 +0800 Subject: [PATCH 12/15] fix(core): retain Gemini content generator aliases --- eslint.legacy-filenames.mjs | 1 + .../geminiContentGenerator/geminiContentGenerator.ts | 8 ++++++++ .../core/src/core/geminiContentGenerator/index.ts | 10 ++++++++++ packages/core/src/index.test.ts | 12 ++++++++++++ 4 files changed, 31 insertions(+) create mode 100644 packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts create mode 100644 packages/core/src/core/geminiContentGenerator/index.ts diff --git a/eslint.legacy-filenames.mjs b/eslint.legacy-filenames.mjs index c4e6c616f4d..50f02ad7166 100644 --- a/eslint.legacy-filenames.mjs +++ b/eslint.legacy-filenames.mjs @@ -133,6 +133,7 @@ export const legacyFilenames = [ 'forkedAgent.cache', 'functionHookRunner', 'geminiChat', + 'geminiContentGenerator', 'geminiRequest', 'generateContentResponseUtilities', 'generatedFiles', diff --git a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts new file mode 100644 index 00000000000..7fafcde65a1 --- /dev/null +++ b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @deprecated Use `LlmContentGenerator`; retained until a future major release. */ +export { LlmContentGenerator as GeminiContentGenerator } from '../llm-content-generator/llm-content-generator.js'; diff --git a/packages/core/src/core/geminiContentGenerator/index.ts b/packages/core/src/core/geminiContentGenerator/index.ts new file mode 100644 index 00000000000..002e1b41f6b --- /dev/null +++ b/packages/core/src/core/geminiContentGenerator/index.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @deprecated Use `LlmContentGenerator`; retained until a future major release. */ +export { LlmContentGenerator as GeminiContentGenerator } from '../llm-content-generator/llm-content-generator.js'; +/** @deprecated Use `createLlmContentGenerator`; retained until a future major release. */ +export { createLlmContentGenerator as createGeminiContentGenerator } from '../llm-content-generator/index.js'; diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts index 6f4e98adfb5..5f72dcc09d5 100644 --- a/packages/core/src/index.test.ts +++ b/packages/core/src/index.test.ts @@ -14,6 +14,15 @@ import { LlmEventType, } from './index.js'; import { LlmChat as LegacyPathLlmChat } from './core/geminiChat.js'; +import { + GeminiContentGenerator as LegacyPathGeminiContentGenerator, + createGeminiContentGenerator as legacyCreateGeminiContentGenerator, +} from './core/geminiContentGenerator/index.js'; +import { GeminiContentGenerator as LegacyLeafGeminiContentGenerator } from './core/geminiContentGenerator/geminiContentGenerator.js'; +import { + LlmContentGenerator, + createLlmContentGenerator, +} from './core/llm-content-generator/index.js'; describe('deprecated LLM rename aliases', () => { it('keeps the published class, enum, and module-path aliases', () => { @@ -21,5 +30,8 @@ describe('deprecated LLM rename aliases', () => { expect(GeminiChat).toBe(LlmChat); expect(GeminiEventType).toBe(LlmEventType); expect(LegacyPathLlmChat).toBe(LlmChat); + expect(LegacyPathGeminiContentGenerator).toBe(LlmContentGenerator); + expect(LegacyLeafGeminiContentGenerator).toBe(LlmContentGenerator); + expect(legacyCreateGeminiContentGenerator).toBe(createLlmContentGenerator); }); }); From 4710d69f241199cfaed3800578f12bb9c8c1a15a Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 26 Aug 2026 20:19:05 +0800 Subject: [PATCH 13/15] docs(core): document legacy content generator paths --- docs/design/2026-08-22-rename-gemini-fork-residue.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/design/2026-08-22-rename-gemini-fork-residue.md b/docs/design/2026-08-22-rename-gemini-fork-residue.md index 9d3f0480875..4bc757bd9dc 100644 --- a/docs/design/2026-08-22-rename-gemini-fork-residue.md +++ b/docs/design/2026-08-22-rename-gemini-fork-residue.md @@ -98,8 +98,9 @@ Non-test files; `gemini-converter.ts` is intentionally NOT renamed (see above). | `packages/core/src/core/geminiContentGenerator/index.ts` | `packages/core/src/core/llm-content-generator/index.ts` | | `packages/core/src/core/geminiRequest.ts` | `packages/core/src/core/llm-request.ts` | -The old `geminiRequest.ts` and `geminiChat.ts` paths remain as deprecated -re-export shims until a future major release. +The old `geminiRequest.ts`, `geminiChat.ts`, and +`geminiContentGenerator/` paths remain as deprecated re-export shims until a +future major release. ## Phasing @@ -134,8 +135,8 @@ move as one atomic PR. `gemini.tsx` → `llm.tsx`. - Protocol converters: `convert*ToGemini*` / `convertGemini*To*` → `Llm`. - Deprecated compatibility aliases for the published core classes, event - types, `Config` client access/initialization option, and old chat module - path. + types, `Config` client access/initialization option, and old chat and content + generator module paths. ## Risks @@ -161,5 +162,5 @@ move as one atomic PR. - `cd packages/cli && npx tsc --noEmit` - Targeted unit tests per renamed module - Legacy-name grep results are confined to the documented compatibility aliases - and `geminiRequest.ts` shim; active repository consumers use the new names. + and re-export shims; active repository consumers use the new names. - `npm run lint` (kebab-case filenames are enforced) From 44352b975b1edc3bfedcb69fea740b5c62bbf476 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 27 Aug 2026 19:33:12 +0800 Subject: [PATCH 14/15] ci: trigger checks after retargeting to main From 7ac8e0603036173e051562f77b28d4fb33501bcf Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 27 Aug 2026 20:03:39 +0800 Subject: [PATCH 15/15] test(cli): update renamed LLM expectations --- packages/cli/src/ui/components/IdeTrustChangeDialog.test.tsx | 5 +++-- .../__snapshots__/HistoryItemDisplay.test.tsx.snap | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/components/IdeTrustChangeDialog.test.tsx b/packages/cli/src/ui/components/IdeTrustChangeDialog.test.tsx index ba53864fe7b..a094132228e 100644 --- a/packages/cli/src/ui/components/IdeTrustChangeDialog.test.tsx +++ b/packages/cli/src/ui/components/IdeTrustChangeDialog.test.tsx @@ -23,7 +23,8 @@ describe('IdeTrustChangeDialog', () => { expect(frameText).toContain( 'Workspace trust has changed due to a change in the IDE connection.', ); - expect(frameText).toContain("Press 'r' to restart Gemini"); + expect(frameText).toContain("Press 'r' to restart Qwen"); + expect(frameText).toContain('Code and apply the changes.'); }); it('renders the correct message for TRUST_CHANGE', () => { @@ -35,7 +36,7 @@ describe('IdeTrustChangeDialog', () => { expect(frameText).toContain( 'Workspace trust has changed due to a change in the IDE trust.', ); - expect(frameText).toContain("Press 'r' to restart Gemini"); + expect(frameText).toContain("Press 'r' to restart Qwen Code"); }); it('renders a generic message for NONE reason', () => { diff --git a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap index f01c2be9747..90d39d6c295 100644 --- a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[` > should render a full gemini item when using availableTerminalHeightGemini 1`] = ` +exports[` > should render a full gemini item when using availableTerminalHeightLlm 1`] = ` " ◆︎ Example code block: 1 Line 1 @@ -55,7 +55,7 @@ exports[` > should render a full gemini item when using av 50 Line 50" `; -exports[` > should render a full gemini_content item when using availableTerminalHeightGemini 1`] = ` +exports[` > should render a full gemini_content item when using availableTerminalHeightLlm 1`] = ` " Example code block: 1 Line 1 2 Line 2