fix(providers): remove client-side context truncation ("second system")

The OpenAICompatibleProvider.truncateHistory() sliding-window dropped
conversation messages based on a hardcoded 20-message cap and a 100k
"safety" token limit — a client-side context manager layered on top of
the provider's own context window. In practice it fired on message count
alone (dropping messages at ~12k tokens, nowhere near the token limit)
and silently corrupted conversation history, mislabeled as "runaway cost"
prevention.

Rip it out entirely. The full history is now sent to the provider, which
owns its own context window.

Removed:
- truncateHistory() + requireNonEmptyToTruncate from OpenAICompatibleProvider
- truncateHistoryForOpenRouter / truncateHistoryForGemini wrappers + constants
- CLAUDE_MEM_{GEMINI,OPENROUTER}_MAX_CONTEXT_MESSAGES / _MAX_TOKENS settings,
  their defaults, and SettingsRoutes validation
- truncation-specific tests; docs + openclaw installer references

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-06-30 20:52:54 -07:00
parent 5def052993
commit 29af028403
15 changed files with 373 additions and 588 deletions

View File

@@ -38,8 +38,6 @@ See [Gemini Provider](usage/gemini-provider) for detailed configuration and free
|----------------------------------------------|-----------------------------|---------------------------------------|
| `CLAUDE_MEM_OPENROUTER_API_KEY` | — | OpenRouter API key ([get key](https://openrouter.ai/keys)) |
| `CLAUDE_MEM_OPENROUTER_MODEL` | `xiaomi/mimo-v2-flash:free` | Model identifier (supports 100+ models) |
| `CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES` | `20` | Max messages in conversation history |
| `CLAUDE_MEM_OPENROUTER_MAX_TOKENS` | `100000` | Token budget safety limit |
| `CLAUDE_MEM_OPENROUTER_SITE_URL` | — | Optional: URL for analytics |
| `CLAUDE_MEM_OPENROUTER_APP_NAME` | `claude-mem` | Optional: App name for analytics |

View File

@@ -74,8 +74,6 @@ All free models support:
| `CLAUDE_MEM_PROVIDER` | `claude`, `gemini`, `openrouter` | `claude` | AI provider for observation extraction |
| `CLAUDE_MEM_OPENROUTER_API_KEY` | string | — | Your OpenRouter API key |
| `CLAUDE_MEM_OPENROUTER_MODEL` | string | `xiaomi/mimo-v2-flash:free` | Model identifier (see list above) |
| `CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES` | number | `20` | Max messages in conversation history |
| `CLAUDE_MEM_OPENROUTER_MAX_TOKENS` | number | `100000` | Token budget safety limit |
| `CLAUDE_MEM_OPENROUTER_SITE_URL` | string | — | Optional: URL for analytics attribution |
| `CLAUDE_MEM_OPENROUTER_APP_NAME` | string | `claude-mem` | Optional: App name for analytics |
@@ -134,21 +132,10 @@ The settings file takes precedence over the environment variable.
## Context Window Management
OpenRouter agent implements intelligent context management to prevent runaway costs:
### Automatic Truncation
The agent uses a sliding window strategy:
1. Checks if message count exceeds `MAX_CONTEXT_MESSAGES` (default: 20)
2. Checks if estimated tokens exceed `MAX_TOKENS` (default: 100,000)
3. If limits exceeded, keeps most recent messages only
4. Logs warnings with dropped message counts
### Token Estimation
- Conservative estimate: 1 token ≈ 4 characters
- Used for proactive context management
- Actual usage logged from API response
The full conversation history is sent to the model on each request. claude-mem does
**not** apply any client-side truncation or message-count cap — the provider and model
own their own context window. If you need to bound context for a specific model, choose a
model with an appropriate context length or manage limits at the OpenRouter/provider level.
### Cost Tracking
@@ -244,12 +231,12 @@ Verify the model ID is correct:
- Use the `:free` suffix for free model variants
- Model IDs are case-sensitive
### High Token Usage Warning
### High Token Usage
If you see warnings about high token usage (>50,000 per request):
- Reduce `CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES`
- Reduce `CLAUDE_MEM_OPENROUTER_MAX_TOKENS`
- Consider a model with larger context window
If a session accumulates a large conversation history, requests can grow. To keep
per-request cost down:
- Choose a model with pricing suited to your usage
- Consider a model with a larger context window if you hit provider-side limits
### Connection Errors

View File

@@ -985,8 +985,6 @@ write_settings() {
CLAUDE_MEM_OPENROUTER_MODEL: 'xiaomi/mimo-v2-flash:free',
CLAUDE_MEM_OPENROUTER_SITE_URL: '',
CLAUDE_MEM_OPENROUTER_APP_NAME: 'claude-mem',
CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES: '20',
CLAUDE_MEM_OPENROUTER_MAX_TOKENS: '100000',
CLAUDE_MEM_DATA_DIR: path.join(homedir, '.claude-mem'),
CLAUDE_MEM_LOG_LEVEL: 'INFO',
CLAUDE_MEM_PYTHON_VERSION: '3.13',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -114,8 +114,6 @@ const GEMINI_RPM_LIMITS: Record<GeminiModel, number> = {
let lastRequestTime = 0;
const DEFAULT_MAX_CONTEXT_MESSAGES = 20;
const DEFAULT_MAX_ESTIMATED_TOKENS = 100000;
const GEMINI_EMPTY_HISTORY_FALLBACK = 'Continue the memory observation request.';
export type GeminiBadRequestCategory =
@@ -222,7 +220,6 @@ interface GeminiConfig {
export class GeminiProvider extends OpenAICompatibleProvider<GeminiConfig> {
protected readonly providerName = 'Gemini';
protected readonly syntheticIdPrefix = 'gemini';
protected readonly requireNonEmptyToTruncate = true;
protected readonly forwardEmptyMessageResponse = false;
constructor(dbManager: DatabaseManager, sessionManager: SessionManager) {
@@ -249,13 +246,6 @@ export class GeminiProvider extends OpenAICompatibleProvider<GeminiConfig> {
: null;
}
protected truncateHistoryForGemini(history: ConversationMessage[]): ConversationMessage[] {
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const MAX_CONTEXT_MESSAGES = parseInt(settings.CLAUDE_MEM_GEMINI_MAX_CONTEXT_MESSAGES) || DEFAULT_MAX_CONTEXT_MESSAGES;
const MAX_ESTIMATED_TOKENS = parseInt(settings.CLAUDE_MEM_GEMINI_MAX_TOKENS) || DEFAULT_MAX_ESTIMATED_TOKENS;
return this.truncateHistory(history, MAX_CONTEXT_MESSAGES, MAX_ESTIMATED_TOKENS);
}
private conversationToGeminiContents(history: ConversationMessage[]): GeminiContent[] {
const contents: GeminiContent[] = [];
let newestNonEmptyContent: string | null = null;
@@ -309,13 +299,11 @@ export class GeminiProvider extends OpenAICompatibleProvider<GeminiConfig> {
model: GeminiModel,
rateLimitingEnabled: boolean
): Promise<ProviderQueryResult> {
const truncatedHistory = this.truncateHistoryForGemini(history);
const contents = this.conversationToGeminiContents(truncatedHistory);
const totalChars = truncatedHistory.reduce((sum, m) => sum + m.content.length, 0);
const contents = this.conversationToGeminiContents(history);
const totalChars = history.reduce((sum, m) => sum + m.content.length, 0);
logger.debug('SDK', `Querying Gemini multi-turn (${model})`, {
turns: truncatedHistory.length,
totalTurns: history.length,
turns: history.length,
totalChars
});

View File

@@ -44,8 +44,6 @@ export abstract class OpenAICompatibleProvider<TConfig extends { apiKey: string;
protected abstract readonly providerName: string;
/** Prefix for the synthetic memorySessionId (e.g. 'gemini', 'openrouter'). */
protected abstract readonly syntheticIdPrefix: string;
/** Gemini guards its truncation loop with `truncated.length > 0`; OpenRouter does not. */
protected abstract readonly requireNonEmptyToTruncate: boolean;
/**
* When a query returns empty content for an observation/summary message:
* OpenRouter still calls processAgentResponse('') (forwards the empty batch
@@ -283,37 +281,4 @@ export abstract class OpenAICompatibleProvider<TConfig extends { apiKey: string;
throw error;
}
protected truncateHistory(history: ConversationMessage[], maxContextMessages: number, maxEstimatedTokens: number): ConversationMessage[] {
if (history.length <= maxContextMessages) {
const totalTokens = history.reduce((sum, m) => sum + this.estimateTokens(m.content), 0);
if (totalTokens <= maxEstimatedTokens) {
return history;
}
}
const truncated: ConversationMessage[] = [];
let tokenCount = 0;
for (let i = history.length - 1; i >= 0; i--) {
const msg = history[i];
const msgTokens = this.estimateTokens(msg.content);
const overLimit = truncated.length >= maxContextMessages || tokenCount + msgTokens > maxEstimatedTokens;
if ((!this.requireNonEmptyToTruncate || truncated.length > 0) && overLimit) {
logger.warn('SDK', 'Context window truncated to prevent runaway costs', {
originalMessages: history.length,
keptMessages: truncated.length,
droppedMessages: i + 1,
estimatedTokens: tokenCount,
tokenLimit: maxEstimatedTokens
});
break;
}
truncated.unshift(msg);
tokenCount += msgTokens;
}
return truncated;
}
}

View File

@@ -94,8 +94,6 @@ export function classifyOpenRouterError(input: {
);
}
const DEFAULT_MAX_CONTEXT_MESSAGES = 20;
const DEFAULT_MAX_ESTIMATED_TOKENS = 100000;
const CHARS_PER_TOKEN_ESTIMATE = 4;
interface OpenAIMessage {
@@ -141,7 +139,6 @@ interface OpenRouterConfig {
export class OpenRouterProvider extends OpenAICompatibleProvider<OpenRouterConfig> {
protected readonly providerName = 'OpenRouter';
protected readonly syntheticIdPrefix = 'openrouter';
protected readonly requireNonEmptyToTruncate = false;
protected readonly forwardEmptyMessageResponse = true;
constructor(dbManager: DatabaseManager, sessionManager: SessionManager) {
@@ -182,13 +179,6 @@ export class OpenRouterProvider extends OpenAICompatibleProvider<OpenRouterConfi
};
}
protected truncateHistoryForOpenRouter(history: ConversationMessage[]): ConversationMessage[] {
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const MAX_CONTEXT_MESSAGES = parseInt(settings.CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES) || DEFAULT_MAX_CONTEXT_MESSAGES;
const MAX_ESTIMATED_TOKENS = parseInt(settings.CLAUDE_MEM_OPENROUTER_MAX_TOKENS) || DEFAULT_MAX_ESTIMATED_TOKENS;
return this.truncateHistory(history, MAX_CONTEXT_MESSAGES, MAX_ESTIMATED_TOKENS);
}
private conversationToOpenAIMessages(history: ConversationMessage[]): OpenAIMessage[] {
return history.map(msg => ({
role: msg.role === 'assistant' ? 'assistant' : 'user',
@@ -208,13 +198,12 @@ export class OpenRouterProvider extends OpenAICompatibleProvider<OpenRouterConfi
siteUrl?: string,
appName?: string
): Promise<ProviderQueryResult> {
const truncatedHistory = this.truncateHistoryForOpenRouter(history);
const messages = this.conversationToOpenAIMessages(truncatedHistory);
const totalChars = truncatedHistory.reduce((sum, m) => sum + m.content.length, 0);
const estimatedTokens = this.estimateTokens(truncatedHistory.map(m => m.content).join(''));
const messages = this.conversationToOpenAIMessages(history);
const totalChars = history.reduce((sum, m) => sum + m.content.length, 0);
const estimatedTokens = this.estimateTokens(history.map(m => m.content).join(''));
logger.debug('SDK', `Querying OpenRouter multi-turn (${model})`, {
turns: truncatedHistory.length,
turns: history.length,
totalChars,
estimatedTokens
});
@@ -310,7 +299,7 @@ export class OpenRouterProvider extends OpenAICompatibleProvider<OpenRouterConfi
outputTokens: realOutputTokens || 0,
totalTokens: tokensUsed,
...(costUsd !== undefined ? { costUSD: costUsd.toFixed(6) } : {}),
messagesInContext: truncatedHistory.length
messagesInContext: history.length
});
if (tokensUsed > 50000) {

View File

@@ -93,14 +93,10 @@ export class SettingsRoutes extends BaseRouteHandler {
'CLAUDE_MEM_GEMINI_API_KEY',
'CLAUDE_MEM_GEMINI_MODEL',
'CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED',
'CLAUDE_MEM_GEMINI_MAX_CONTEXT_MESSAGES',
'CLAUDE_MEM_GEMINI_MAX_TOKENS',
'CLAUDE_MEM_OPENROUTER_API_KEY',
'CLAUDE_MEM_OPENROUTER_MODEL',
'CLAUDE_MEM_OPENROUTER_SITE_URL',
'CLAUDE_MEM_OPENROUTER_APP_NAME',
'CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES',
'CLAUDE_MEM_OPENROUTER_MAX_TOKENS',
'CLAUDE_MEM_DATA_DIR',
'CLAUDE_MEM_LOG_LEVEL',
'CLAUDE_MEM_PYTHON_VERSION',
@@ -211,20 +207,6 @@ export class SettingsRoutes extends BaseRouteHandler {
}
}
if (settings.CLAUDE_MEM_GEMINI_MAX_CONTEXT_MESSAGES) {
const count = parseInt(settings.CLAUDE_MEM_GEMINI_MAX_CONTEXT_MESSAGES, 10);
if (isNaN(count) || count < 1 || count > 100) {
return { valid: false, error: 'CLAUDE_MEM_GEMINI_MAX_CONTEXT_MESSAGES must be between 1 and 100' };
}
}
if (settings.CLAUDE_MEM_GEMINI_MAX_TOKENS) {
const tokens = parseInt(settings.CLAUDE_MEM_GEMINI_MAX_TOKENS, 10);
if (isNaN(tokens) || tokens < 1000 || tokens > 1000000) {
return { valid: false, error: 'CLAUDE_MEM_GEMINI_MAX_TOKENS must be between 1000 and 1000000' };
}
}
if (settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS) {
const obsCount = parseInt(settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS, 10);
if (isNaN(obsCount) || obsCount < 1 || obsCount > 200) {
@@ -296,20 +278,6 @@ export class SettingsRoutes extends BaseRouteHandler {
}
}
if (settings.CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES) {
const count = parseInt(settings.CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES, 10);
if (isNaN(count) || count < 1 || count > 100) {
return { valid: false, error: 'CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES must be between 1 and 100' };
}
}
if (settings.CLAUDE_MEM_OPENROUTER_MAX_TOKENS) {
const tokens = parseInt(settings.CLAUDE_MEM_OPENROUTER_MAX_TOKENS, 10);
if (isNaN(tokens) || tokens < 1000 || tokens > 1000000) {
return { valid: false, error: 'CLAUDE_MEM_OPENROUTER_MAX_TOKENS must be between 1000 and 1000000' };
}
}
if (settings.CLAUDE_MEM_OPENROUTER_SITE_URL) {
try {
new URL(settings.CLAUDE_MEM_OPENROUTER_SITE_URL);

View File

@@ -16,15 +16,11 @@ export interface SettingsDefaults {
CLAUDE_MEM_GEMINI_API_KEY: string;
CLAUDE_MEM_GEMINI_MODEL: string;
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: string;
CLAUDE_MEM_GEMINI_MAX_CONTEXT_MESSAGES: string;
CLAUDE_MEM_GEMINI_MAX_TOKENS: string;
CLAUDE_MEM_OPENROUTER_API_KEY: string;
CLAUDE_MEM_OPENROUTER_MODEL: string;
CLAUDE_MEM_OPENROUTER_BASE_URL: string;
CLAUDE_MEM_OPENROUTER_SITE_URL: string;
CLAUDE_MEM_OPENROUTER_APP_NAME: string;
CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES: string;
CLAUDE_MEM_OPENROUTER_MAX_TOKENS: string;
CLAUDE_MEM_DATA_DIR: string;
CLAUDE_MEM_LOG_LEVEL: string;
CLAUDE_MEM_PYTHON_VERSION: string;
@@ -104,15 +100,11 @@ export class SettingsDefaultsManager {
CLAUDE_MEM_GEMINI_API_KEY: '', // Empty by default, can be set via UI or env
CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite', // Default Gemini model (highest free tier RPM)
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'true', // Rate limiting ON by default for free tier users
CLAUDE_MEM_GEMINI_MAX_CONTEXT_MESSAGES: '20', // Max messages in Gemini context window
CLAUDE_MEM_GEMINI_MAX_TOKENS: '100000', // Max estimated tokens (~100k safety limit)
CLAUDE_MEM_OPENROUTER_API_KEY: '', // Empty by default, can be set via UI or env
CLAUDE_MEM_OPENROUTER_MODEL: 'xiaomi/mimo-v2-flash:free', // Default OpenRouter model (free tier)
CLAUDE_MEM_OPENROUTER_BASE_URL: '', // #2382/#2590/#2622/#2393 — optional OpenAI-compatible base URL (e.g. https://api.deepseek.com, http://localhost:1234/v1). Empty = default OpenRouter endpoint.
CLAUDE_MEM_OPENROUTER_SITE_URL: '', // Optional: for OpenRouter analytics
CLAUDE_MEM_OPENROUTER_APP_NAME: 'claude-mem', // App name for OpenRouter analytics
CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES: '20', // Max messages in context window
CLAUDE_MEM_OPENROUTER_MAX_TOKENS: '100000', // Max estimated tokens (~100k safety limit)
CLAUDE_MEM_DATA_DIR: join(homedir(), '.claude-mem'),
CLAUDE_MEM_LOG_LEVEL: 'INFO',
CLAUDE_MEM_PYTHON_VERSION: '3.13',

View File

@@ -41,14 +41,12 @@ function makeSession(overrides: Record<string, unknown> = {}) {
} as any;
}
function mockGeminiLimits(maxContextMessages: string) {
function mockGeminiConfig() {
loadFromFileSpy.mockImplementation(() => ({
...SettingsDefaultsManager.getAllDefaults(),
CLAUDE_MEM_GEMINI_API_KEY: 'test-api-key',
CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite',
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'false',
CLAUDE_MEM_GEMINI_MAX_CONTEXT_MESSAGES: maxContextMessages,
CLAUDE_MEM_GEMINI_MAX_TOKENS: '100000',
CLAUDE_MEM_DATA_DIR: '/tmp/claude-mem-test',
}));
}
@@ -242,30 +240,7 @@ describe('GeminiProvider', () => {
expect(body.contents[2].role).toBe('user');
});
it('repairs truncated history that would otherwise start with a model turn', async () => {
mockGeminiLimits('2');
const session = makeSession({
userPrompt: 'current user prompt',
lastPromptNumber: 2,
conversationHistory: [
{ role: 'user', content: 'old user turn' },
{ role: 'assistant', content: 'assistant turn kept by truncation' },
],
});
mockSuccessfulGeminiFetch();
await agent.startSession(session);
const contents = sentGeminiContents();
expectAlternatingGeminiRoles(contents);
expect(contents[0].role).toBe('user');
expect(contents[0].parts[0].text).toContain('current user prompt');
expect(contents.map((content: any) => content.parts[0].text).join('\n')).not.toContain('assistant turn kept by truncation');
});
it('keeps Gemini roles alternating with odd and even context message limits', async () => {
it('keeps Gemini roles alternating for full conversation history', async () => {
const history = [
{ role: 'user', content: 'u0' },
{ role: 'assistant', content: 'm1' },
@@ -275,12 +250,12 @@ describe('GeminiProvider', () => {
{ role: 'assistant', content: 'm5' },
];
for (const maxContextMessages of ['4', '5']) {
mockGeminiLimits(maxContextMessages);
for (const label of ['a', 'b']) {
mockGeminiConfig();
mockSuccessfulGeminiFetch();
await agent.startSession(makeSession({
userPrompt: `current prompt ${maxContextMessages}`,
userPrompt: `current prompt ${label}`,
lastPromptNumber: 2,
conversationHistory: history.map(message => ({ ...message })),
}));
@@ -288,7 +263,7 @@ describe('GeminiProvider', () => {
const contents = sentGeminiContents();
expectAlternatingGeminiRoles(contents);
expect(contents[contents.length - 1].role).toBe('user');
expect(contents[contents.length - 1].parts[0].text).toContain(`current prompt ${maxContextMessages}`);
expect(contents[contents.length - 1].parts[0].text).toContain(`current prompt ${label}`);
}
});
@@ -489,81 +464,6 @@ describe('GeminiProvider', () => {
}
});
describe('conversation history truncation', () => {
it('should truncate history when message count exceeds limit', async () => {
const history: any[] = [];
for (let i = 0; i < 25; i++) {
history.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `message ${i}` });
}
const session = {
sessionDbId: 1,
contentSessionId: 'test-session',
memorySessionId: 'mem-session-123',
project: 'test-project',
userPrompt: 'test prompt',
conversationHistory: history,
lastPromptNumber: 2,
cumulativeInputTokens: 0,
cumulativeOutputTokens: 0,
pendingMessages: [],
abortController: new AbortController(),
generatorPromise: null,
currentProvider: null,
startTime: Date.now(),
} as any;
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
candidates: [{ content: { parts: [{ text: 'response' }] } }]
}))));
await agent.startSession(session);
const body = JSON.parse((global.fetch as any).mock.calls[0][1].body);
expect(body.contents.length).toBeLessThanOrEqual(20);
});
it('should always keep at least the newest message even if it exceeds token limit', async () => {
loadFromFileSpy.mockImplementation(() => ({
...SettingsDefaultsManager.getAllDefaults(),
CLAUDE_MEM_GEMINI_API_KEY: 'test-api-key',
CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite',
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'false',
CLAUDE_MEM_GEMINI_MAX_CONTEXT_MESSAGES: '20',
CLAUDE_MEM_GEMINI_MAX_TOKENS: '1000', // Very low: ~250 chars
CLAUDE_MEM_DATA_DIR: '/tmp/claude-mem-test',
}));
const largeContent = 'x'.repeat(8000);
const session = {
sessionDbId: 1,
contentSessionId: 'test-session',
memorySessionId: 'mem-session-123',
project: 'test-project',
userPrompt: largeContent,
conversationHistory: [],
lastPromptNumber: 1,
cumulativeInputTokens: 0,
cumulativeOutputTokens: 0,
pendingMessages: [],
abortController: new AbortController(),
generatorPromise: null,
currentProvider: null,
startTime: Date.now(),
} as any;
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
candidates: [{ content: { parts: [{ text: 'response' }] } }]
}))));
await agent.startSession(session);
const body = JSON.parse((global.fetch as any).mock.calls[0][1].body);
expect(body.contents.length).toBeGreaterThanOrEqual(1);
});
});
describe('gemini-3-flash-preview model support', () => {
it('should accept gemini-3-flash-preview as a valid model', async () => {
const validModels = [