/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import { toParts } from '../code_assist/converter.js';
import { createUserContent } from '@google/genai';
import { retryWithBackoff } from '../utils/retry.js';
import { DEFAULT_GEMINI_FLASH_MODEL, getEffectiveModel, } from '../config/models.js';
import { hasCycleInSchema } from '../tools/tools.js';
import { logContentRetry, logContentRetryFailure, } from '../telemetry/loggers.js';
import { ChatRecordingService } from '../services/chatRecordingService.js';
import { ContentRetryEvent, ContentRetryFailureEvent, } from '../telemetry/types.js';
import { handleFallback } from '../fallback/handler.js';
import { isFunctionResponse } from '../utils/messageInspectors.js';
import { partListUnionToString } from './geminiRequest.js';
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
export var StreamEventType;
(function (StreamEventType) {
    /** A regular content chunk from the API. */
    StreamEventType["CHUNK"] = "chunk";
    /** A signal that a retry is about to happen. The UI should discard any partial
     * content from the attempt that just failed. */
    StreamEventType["RETRY"] = "retry";
})(StreamEventType || (StreamEventType = {}));
const INVALID_CONTENT_RETRY_OPTIONS = {
    maxAttempts: 2, // 1 initial call + 1 retry
    initialDelayMs: 500,
};
/**
 * Returns true if the response is valid, false otherwise.
 */
function isValidResponse(response) {
    if (response.candidates === undefined || response.candidates.length === 0) {
        return false;
    }
    const content = response.candidates[0]?.content;
    if (content === undefined) {
        return false;
    }
    return isValidContent(content);
}
export function isValidNonThoughtTextPart(part) {
    return (typeof part.text === 'string' &&
        !part.thought &&
        // 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) {
    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 (!part.thought && part.text !== undefined && part.text === '') {
            return false;
        }
    }
    return true;
}
/**
 * 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) {
    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) {
    if (comprehensiveHistory === undefined || comprehensiveHistory.length === 0) {
        return [];
    }
    const curatedHistory = [];
    const length = comprehensiveHistory.length;
    let i = 0;
    while (i < length) {
        if (comprehensiveHistory[i].role === 'user') {
            curatedHistory.push(comprehensiveHistory[i]);
            i++;
        }
        else {
            const modelOutput = [];
            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);
            }
        }
    }
    return curatedHistory;
}
/**
 * Custom error to signal that a stream completed with invalid content,
 * which should trigger a retry.
 */
export class InvalidStreamError extends Error {
    type;
    constructor(message, type) {
        super(message);
        this.name = 'InvalidStreamError';
        this.type = type;
    }
}
/**
 * Chat session that enables sending messages to the model with previous
 * conversation context.
 *
 * @remarks
 * The session maintains all the turns between user and model.
 */
export class GeminiChat {
    config;
    generationConfig;
    history;
    // A promise to represent the current state of the message being sent to the
    // model.
    sendPromise = Promise.resolve();
    chatRecordingService;
    constructor(config, generationConfig = {}, history = []) {
        this.config = config;
        this.generationConfig = generationConfig;
        this.history = history;
        validateHistory(history);
        this.chatRecordingService = new ChatRecordingService(config);
        this.chatRecordingService.initialize();
    }
    setSystemInstruction(sysInstr) {
        this.generationConfig.systemInstruction = sysInstr;
    }
    /**
     * 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, params, prompt_id) {
        await this.sendPromise;
        let streamDoneResolver;
        const streamDonePromise = new Promise((resolve) => {
            streamDoneResolver = resolve;
        });
        this.sendPromise = streamDonePromise;
        const userContent = createUserContent(params.message);
        // Record user input - capture complete message with all parts (text, files, images, etc.)
        // but skip recording function responses (tool call results) as they should be stored in tool call records
        if (!isFunctionResponse(userContent)) {
            const userMessage = Array.isArray(params.message)
                ? params.message
                : [params.message];
            const userMessageContent = partListUnionToString(toParts(userMessage));
            this.chatRecordingService.recordMessage({
                model,
                type: 'user',
                content: userMessageContent,
            });
        }
        // Add user content to history ONCE before any attempts.
        this.history.push(userContent);
        const requestContents = this.getHistory(true);
        // eslint-disable-next-line @typescript-eslint/no-this-alias
        const self = this;
        return (async function* () {
            try {
                let lastError = new Error('Request failed after all retries.');
                for (let attempt = 0; attempt < INVALID_CONTENT_RETRY_OPTIONS.maxAttempts; attempt++) {
                    try {
                        if (attempt > 0) {
                            yield { type: StreamEventType.RETRY };
                        }
                        // If this is a retry, set temperature to 1 to encourage different output.
                        const currentParams = { ...params };
                        if (attempt > 0) {
                            currentParams.config = {
                                ...currentParams.config,
                                temperature: 1,
                            };
                        }
                        const stream = await self.makeApiCallAndProcessStream(model, requestContents, currentParams, prompt_id);
                        for await (const chunk of stream) {
                            yield { type: StreamEventType.CHUNK, value: chunk };
                        }
                        lastError = null;
                        break;
                    }
                    catch (error) {
                        lastError = error;
                        const isContentError = error instanceof InvalidStreamError;
                        if (isContentError) {
                            // Check if we have more attempts left.
                            if (attempt < INVALID_CONTENT_RETRY_OPTIONS.maxAttempts - 1) {
                                logContentRetry(self.config, new ContentRetryEvent(attempt, error.type, INVALID_CONTENT_RETRY_OPTIONS.initialDelayMs, model));
                                await new Promise((res) => setTimeout(res, INVALID_CONTENT_RETRY_OPTIONS.initialDelayMs *
                                    (attempt + 1)));
                                continue;
                            }
                        }
                        break;
                    }
                }
                if (lastError) {
                    if (lastError instanceof InvalidStreamError) {
                        logContentRetryFailure(self.config, new ContentRetryFailureEvent(INVALID_CONTENT_RETRY_OPTIONS.maxAttempts, lastError.type, model));
                    }
                    throw lastError;
                }
            }
            finally {
                streamDoneResolver();
            }
        })();
    }
    async makeApiCallAndProcessStream(model, requestContents, params, prompt_id) {
        const apiCall = () => {
            const modelToUse = getEffectiveModel(this.config.isInFallbackMode(), model);
            if (this.config.getQuotaErrorOccurred() &&
                modelToUse === DEFAULT_GEMINI_FLASH_MODEL) {
                throw new Error('Please submit a new query to continue with the Flash model.');
            }
            return this.config.getContentGenerator().generateContentStream({
                model: modelToUse,
                contents: requestContents,
                config: { ...this.generationConfig, ...params.config },
            }, prompt_id);
        };
        const onPersistent429Callback = async (authType, error) => await handleFallback(this.config, model, authType, error);
        const streamResponse = await retryWithBackoff(apiCall, {
            onPersistent429: onPersistent429Callback,
            authType: this.config.getContentGeneratorConfig()?.authType,
            retryFetchErrors: this.config.getRetryFetchErrors(),
            signal: params.config?.abortSignal,
        });
        return this.processStreamResponse(model, streamResponse);
    }
    /**
     * 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 = false) {
        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);
    }
    /**
     * Clears the chat history.
     */
    clearHistory() {
        this.history = [];
    }
    /**
     * Adds a new entry to the chat history.
     */
    addHistory(content) {
        this.history.push(content);
    }
    setHistory(history) {
        this.history = history;
    }
    stripThoughtsFromHistory() {
        this.history = this.history.map((content) => {
            const newContent = { ...content };
            if (newContent.parts) {
                newContent.parts = newContent.parts.map((part) => {
                    if (part && typeof part === 'object' && 'thoughtSignature' in part) {
                        const newPart = { ...part };
                        delete newPart.thoughtSignature;
                        return newPart;
                    }
                    return part;
                });
            }
            return newContent;
        });
    }
    setTools(tools) {
        this.generationConfig.tools = tools;
    }
    async maybeIncludeSchemaDepthContext(error) {
        // 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 tools = this.config.getToolRegistry().getAllTools();
            const cyclicSchemaTools = [];
            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;
            }
        }
    }
    async *processStreamResponse(model, streamResponse) {
        const modelResponseParts = [];
        let hasToolCall = false;
        let hasFinishReason = false;
        for await (const chunk of streamResponse) {
            hasFinishReason =
                chunk?.candidates?.some((candidate) => candidate.finishReason) ?? false;
            if (isValidResponse(chunk)) {
                const content = chunk.candidates?.[0]?.content;
                if (content?.parts) {
                    if (content.parts.some((part) => part.thought)) {
                        // Record thoughts
                        this.recordThoughtFromContent(content);
                    }
                    if (content.parts.some((part) => part.functionCall)) {
                        hasToolCall = true;
                    }
                    modelResponseParts.push(...content.parts.filter((part) => !part.thought));
                }
            }
            // Record token usage if this chunk has usageMetadata
            if (chunk.usageMetadata) {
                this.chatRecordingService.recordMessageTokens(chunk.usageMetadata);
                if (chunk.usageMetadata.promptTokenCount !== undefined) {
                    uiTelemetryService.setLastPromptTokenCount(chunk.usageMetadata.promptTokenCount);
                }
            }
            yield chunk; // Yield every chunk to the UI immediately.
        }
        // String thoughts and consolidate text parts.
        const consolidatedParts = [];
        for (const part of modelResponseParts) {
            const lastPart = consolidatedParts[consolidatedParts.length - 1];
            if (lastPart?.text &&
                isValidNonThoughtTextPart(lastPart) &&
                isValidNonThoughtTextPart(part)) {
                lastPart.text += part.text;
            }
            else {
                consolidatedParts.push(part);
            }
        }
        const responseText = consolidatedParts
            .filter((part) => part.text)
            .map((part) => part.text)
            .join('')
            .trim();
        // Record model response text from the collected parts
        if (responseText) {
            this.chatRecordingService.recordMessage({
                model,
                type: 'gemini',
                content: responseText,
            });
        }
        // 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
        //
        // We throw an error only when there's no tool call AND:
        // - No finish reason, OR
        // - Empty response text (e.g., only thoughts with no actual content)
        if (!hasToolCall && (!hasFinishReason || !responseText)) {
            if (!hasFinishReason) {
                throw new InvalidStreamError('Model stream ended without a finish reason.', 'NO_FINISH_REASON');
            }
            else {
                throw new InvalidStreamError('Model stream ended with empty response text.', 'NO_RESPONSE_TEXT');
            }
        }
        this.history.push({ role: 'model', parts: consolidatedParts });
    }
    /**
     * Gets the chat recording service instance.
     */
    getChatRecordingService() {
        return this.chatRecordingService;
    }
    /**
     * Records completed tool calls with full metadata.
     * This is called by external components when tool calls complete, before sending responses to Gemini.
     */
    recordCompletedToolCalls(model, toolCalls) {
        const toolCallRecords = toolCalls.map((call) => {
            const resultDisplayRaw = call.response?.resultDisplay;
            const resultDisplay = typeof resultDisplayRaw === 'string' ? resultDisplayRaw : undefined;
            return {
                id: call.request.callId,
                name: call.request.name,
                args: call.request.args,
                result: call.response?.responseParts || null,
                status: call.status,
                timestamp: new Date().toISOString(),
                resultDisplay,
            };
        });
        this.chatRecordingService.recordToolCalls(model, toolCallRecords);
    }
    /**
     * Extracts and records thought from thought content.
     */
    recordThoughtFromContent(content) {
        if (!content.parts || content.parts.length === 0) {
            return;
        }
        const thoughtPart = content.parts[0];
        if (thoughtPart.text) {
            // Extract subject and description using the same logic as turn.ts
            const rawText = thoughtPart.text;
            const subjectStringMatches = rawText.match(/\*\*(.*?)\*\*/s);
            const subject = subjectStringMatches
                ? subjectStringMatches[1].trim()
                : '';
            const description = rawText.replace(/\*\*(.*?)\*\*/s, '').trim();
            this.chatRecordingService.recordThought({
                subject,
                description,
            });
        }
    }
}
/** Visible for Testing */
export function isSchemaDepthError(errorMessage) {
    return errorMessage.includes('maximum schema depth exceeded');
}
export function isInvalidArgumentError(errorMessage) {
    return errorMessage.includes('Request contains an invalid argument');
}
//# sourceMappingURL=geminiChat.js.map