diff --git a/src/drivers/agent/agent-stream.test.ts b/src/drivers/agent/agent-stream.test.ts
new file mode 100644
index 0000000..dbd5111
--- /dev/null
+++ b/src/drivers/agent/agent-stream.test.ts
@@ -0,0 +1,273 @@
+import { BaseDriver } from "../base-driver";
+import { AgentStreamEvent } from "./base";
+import { AnthropicDriver } from "./anthropic";
+import { ChatGPTDriver } from "./chatgpt";
+import { GeminiDriver } from "./gemini";
+
+// Driver de DB falso: los queryStream no usan flags, pero el constructor pide uno.
+function fakeDriver(): BaseDriver {
+ return {
+ getFlags: () => ({ dialect: "postgres", defaultSchema: "public" }),
+ escapeId: (id: string) => `"${id}"`,
+ } as unknown as BaseDriver;
+}
+
+// Response con un body ReadableStream que emite los chunks dados (como si fueran los
+// trozos que llegan por la red). Permite partir un evento SSE en varios chunks.
+function sseResponse(chunks: string[]): Response {
+ const encoder = new TextEncoder();
+ const body = new ReadableStream
({
+ start(controller) {
+ for (const c of chunks) controller.enqueue(encoder.encode(c));
+ controller.close();
+ },
+ });
+ return { ok: true, body } as unknown as Response;
+}
+
+const httpError = (status = 401): Response =>
+ ({
+ ok: false,
+ status,
+ statusText: "Unauthorized",
+ text: async () => JSON.stringify({ error: { message: "bad key" } }),
+ }) as unknown as Response;
+
+const collect = () => {
+ const events: AgentStreamEvent[] = [];
+ return { events, push: (e: AgentStreamEvent) => events.push(e) };
+};
+
+const texts = (events: AgentStreamEvent[]) =>
+ events.filter((e) => e.type === "text").map((e) => (e as { delta: string }).delta);
+const reasonings = (events: AgentStreamEvent[]) =>
+ events
+ .filter((e) => e.type === "reasoning")
+ .map((e) => (e as { delta: string }).delta);
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+describe("ChatGPTDriver.queryStream — SSE de OpenAI", () => {
+ test("acumula choices[].delta.content y corta en [DONE]", async () => {
+ global.fetch = jest.fn().mockResolvedValue(
+ sseResponse([
+ 'data: {"choices":[{"delta":{"content":"SE"}}]}\n\n',
+ 'data: {"choices":[{"delta":{"content":"LECT 1"}}]}\n\n',
+ "data: [DONE]\n\n",
+ ])
+ ) as unknown as typeof fetch;
+
+ const driver = new ChatGPTDriver(fakeDriver(), "sk", "gpt-4o");
+ const c = collect();
+ const out = await driver.queryStream([{ role: "user", content: "hi" }], c.push);
+
+ expect(out).toBe("SELECT 1");
+ expect(texts(c.events)).toEqual(["SE", "LECT 1"]);
+ // OpenAI por chat-completions NO emite reasoning.
+ expect(reasonings(c.events)).toEqual([]);
+ });
+
+ test("manda stream:true en el body", async () => {
+ const fetchSpy = jest
+ .fn()
+ .mockResolvedValue(sseResponse(["data: [DONE]\n\n"]));
+ global.fetch = fetchSpy as unknown as typeof fetch;
+
+ const driver = new ChatGPTDriver(fakeDriver(), "sk", "gpt-4o");
+ await driver.queryStream([{ role: "user", content: "hi" }], () => {});
+
+ const body = JSON.parse(
+ (fetchSpy.mock.calls[0][1] as RequestInit).body as string
+ );
+ expect(body.stream).toBe(true);
+ expect(body.model).toBe("gpt-4o");
+ });
+
+ test("reensambla un evento partido en varios chunks", async () => {
+ global.fetch = jest.fn().mockResolvedValue(
+ sseResponse([
+ 'data: {"choi',
+ 'ces":[{"delta":{"content":"X"}}]}\n\n',
+ "data: [DONE]\n\n",
+ ])
+ ) as unknown as typeof fetch;
+
+ const driver = new ChatGPTDriver(fakeDriver(), "sk", "gpt-4o");
+ const c = collect();
+ const out = await driver.queryStream([{ role: "user", content: "hi" }], c.push);
+ expect(out).toBe("X");
+ });
+
+ test("HTTP no-ok → tira (para que chatStream caiga al fallback)", async () => {
+ global.fetch = jest
+ .fn()
+ .mockResolvedValue(httpError(401)) as unknown as typeof fetch;
+
+ const driver = new ChatGPTDriver(fakeDriver(), "sk", "gpt-4o");
+ await expect(
+ driver.queryStream([{ role: "user", content: "hi" }], () => {})
+ ).rejects.toThrow("bad key");
+ });
+});
+
+describe("AnthropicDriver.queryStream — SSE de Anthropic (text + thinking)", () => {
+ test("separa thinking_delta (reasoning) de text_delta (texto) y corta en message_stop", async () => {
+ global.fetch = jest.fn().mockResolvedValue(
+ sseResponse([
+ 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":"pienso"}}\n\n',
+ 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"SELECT 1"}}\n\n',
+ 'event: message_stop\ndata: {"type":"message_stop"}\n\n',
+ ])
+ ) as unknown as typeof fetch;
+
+ const driver = new AnthropicDriver(fakeDriver(), "sk-ant", "claude-opus-4-8");
+ const c = collect();
+ const out = await driver.queryStream([{ role: "user", content: "hi" }], c.push);
+
+ expect(out).toBe("SELECT 1");
+ expect(texts(c.events)).toEqual(["SELECT 1"]);
+ expect(reasonings(c.events)).toEqual(["pienso"]);
+ });
+
+ test("pide thinking + stream en el body", async () => {
+ const fetchSpy = jest.fn().mockResolvedValue(
+ sseResponse(['event: message_stop\ndata: {"type":"message_stop"}\n\n'])
+ );
+ global.fetch = fetchSpy as unknown as typeof fetch;
+
+ const driver = new AnthropicDriver(fakeDriver(), "sk-ant", "claude-opus-4-8");
+ await driver.queryStream([{ role: "user", content: "hi" }], () => {});
+
+ const body = JSON.parse(
+ (fetchSpy.mock.calls[0][1] as RequestInit).body as string
+ );
+ expect(body.stream).toBe(true);
+ expect(body.thinking).toEqual({ type: "enabled", budget_tokens: 1024 });
+ });
+
+ test("un evento error en el stream → tira", async () => {
+ global.fetch = jest.fn().mockResolvedValue(
+ sseResponse([
+ 'event: error\ndata: {"type":"error","error":{"message":"overloaded"}}\n\n',
+ ])
+ ) as unknown as typeof fetch;
+
+ const driver = new AnthropicDriver(fakeDriver(), "sk-ant", "claude-opus-4-8");
+ await expect(
+ driver.queryStream([{ role: "user", content: "hi" }], () => {})
+ ).rejects.toThrow("overloaded");
+ });
+});
+
+describe("GeminiDriver.queryStream — SSE de Gemini (thought vs texto)", () => {
+ test("parts con thought:true → reasoning, el resto → texto", async () => {
+ global.fetch = jest.fn().mockResolvedValue(
+ sseResponse([
+ 'data: {"candidates":[{"content":{"parts":[{"text":"razono","thought":true}]}}]}\n\n',
+ 'data: {"candidates":[{"content":{"parts":[{"text":"SELECT 1"}]}}]}\n\n',
+ ])
+ ) as unknown as typeof fetch;
+
+ const driver = new GeminiDriver(fakeDriver(), "g-key", "gemini-2.0-flash");
+ const c = collect();
+ const out = await driver.queryStream([{ role: "user", content: "hi" }], c.push);
+
+ expect(out).toBe("SELECT 1");
+ expect(texts(c.events)).toEqual(["SELECT 1"]);
+ expect(reasonings(c.events)).toEqual(["razono"]);
+ });
+
+ test("usa el endpoint :streamGenerateContent?alt=sse", async () => {
+ const fetchSpy = jest.fn().mockResolvedValue(sseResponse([]));
+ global.fetch = fetchSpy as unknown as typeof fetch;
+
+ const driver = new GeminiDriver(fakeDriver(), "g-key", "gemini-2.0-flash");
+ await driver.queryStream([{ role: "user", content: "hi" }], () => {});
+
+ const url = fetchSpy.mock.calls[0][0] as string;
+ expect(url).toContain(":streamGenerateContent");
+ expect(url).toContain("alt=sse");
+ });
+});
+
+describe("chatStream — hard fallback al query() no-streaming", () => {
+ test("si el stream no arranca (HTTP no-ok), cae a query() y emite un único text + done", async () => {
+ // 1ª llamada (queryStream): HTTP no-ok → tira. 2ª (query no-stream): responde OK.
+ global.fetch = jest
+ .fn()
+ .mockResolvedValueOnce(httpError(500))
+ .mockResolvedValueOnce({
+ json: async () => ({
+ choices: [{ message: { role: "assistant", content: "SELECT 42" } }],
+ }),
+ } as Response) as unknown as typeof fetch;
+
+ const driver = new ChatGPTDriver(fakeDriver(), "sk", "gpt-4o");
+ const c = collect();
+ const out = await driver.chatStream(
+ "dame un número",
+ undefined,
+ { selected: "" },
+ c.push
+ );
+
+ expect(out).toBe("SELECT 42");
+ expect(texts(c.events)).toEqual(["SELECT 42"]);
+ expect(c.events.some((e) => e.type === "done")).toBe(true);
+ });
+
+ test("si solo hubo reasoning antes del fallo, cae al fallback (no se pierde la respuesta)", async () => {
+ // 1ª (queryStream): emite thinking (reasoning) y después un evento error → tira,
+ // pero con text vacío. 2ª (query no-stream): responde OK. El fallback debe correr.
+ global.fetch = jest
+ .fn()
+ .mockResolvedValueOnce(
+ sseResponse([
+ 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":"pienso"}}\n\n',
+ 'event: error\ndata: {"type":"error","error":{"message":"overloaded"}}\n\n',
+ ])
+ )
+ .mockResolvedValueOnce({
+ json: async () => ({
+ content: [{ type: "text", text: "SELECT 99" }],
+ }),
+ } as Response) as unknown as typeof fetch;
+
+ const driver = new AnthropicDriver(fakeDriver(), "sk-ant", "claude-opus-4-8");
+ const c = collect();
+ const out = await driver.chatStream(
+ "hola",
+ undefined,
+ { selected: "" },
+ c.push
+ );
+
+ expect(out).toBe("SELECT 99");
+ expect(reasonings(c.events)).toEqual(["pienso"]);
+ expect(texts(c.events)).toEqual(["SELECT 99"]);
+ expect(c.events[c.events.length - 1]).toEqual({ type: "done" });
+ });
+
+ test("camino feliz: streamea y persiste el texto acumulado", async () => {
+ global.fetch = jest.fn().mockResolvedValue(
+ sseResponse([
+ 'data: {"choices":[{"delta":{"content":"hola"}}]}\n\n',
+ "data: [DONE]\n\n",
+ ])
+ ) as unknown as typeof fetch;
+
+ const driver = new ChatGPTDriver(fakeDriver(), "sk", "gpt-4o");
+ const c = collect();
+ const out = await driver.chatStream(
+ "hi",
+ undefined,
+ { selected: "" },
+ c.push
+ );
+
+ expect(out).toBe("hola");
+ expect(c.events[c.events.length - 1]).toEqual({ type: "done" });
+ });
+});
diff --git a/src/drivers/agent/agent.test.ts b/src/drivers/agent/agent.test.ts
index 0806363..970669b 100644
--- a/src/drivers/agent/agent.test.ts
+++ b/src/drivers/agent/agent.test.ts
@@ -23,6 +23,11 @@ class TestDriver extends CommonAgentDriverImplementation {
return "";
}
+ // Stub: estos tests no ejercitan el streaming (ver agent-stream.test.ts).
+ async queryStream(_messages: CommonAgentMessage[]): Promise {
+ return "";
+ }
+
publicConvertTableToDDLContent(
schemaName: string | undefined,
table: DatabaseTableSchema
diff --git a/src/drivers/agent/anthropic.ts b/src/drivers/agent/anthropic.ts
index 20f25cd..5780015 100644
--- a/src/drivers/agent/anthropic.ts
+++ b/src/drivers/agent/anthropic.ts
@@ -1,11 +1,30 @@
import { BaseDriver } from "../base-driver";
+import { AgentStreamCallback } from "./base";
import CommonAgentDriverImplementation, { CommonAgentMessage } from "./common";
+import { readSSEStream, readStreamError } from "./sse";
interface AnthropicResponse {
content: { type: string; text?: string }[];
error?: { message: string };
}
+// Evento SSE de Anthropic (Messages API, stream:true). El campo `type` discrimina:
+// content_block_delta trae delta.type === "text_delta" (texto) o "thinking_delta"
+// (reasoning); message_stop cierra; error reporta un fallo.
+interface AnthropicStreamEvent {
+ type?: string;
+ delta?: { type?: string; text?: string; thinking?: string };
+ error?: { message?: string };
+}
+
+// Presupuesto de "extended thinking" para que Claude exponga su razonamiento en el
+// stream. Es opt-in y best-effort: en modelos que lo soportan (familia claude-4 /
+// 3.7) aparece como bloque "Razonando…"; en los que NO, la API tira 400 y chatStream
+// cae al query() no-streaming (sin reasoning, pero responde igual). max_tokens debe
+// ser > budget_tokens (4096 > 1024).
+const THINKING_BUDGET_TOKENS = 1024;
+const STREAM_MAX_TOKENS = 4096;
+
/**
* Driver de agente para Anthropic (Claude). Default: claude-opus-4-8.
* La key se pasa por constructor (viene de localStorage, nunca se commitea/loguea).
@@ -56,6 +75,76 @@ export class AnthropicDriver extends CommonAgentDriverImplementation {
return text;
}
+ // Streaming SSE (stream:true) con reasoning best-effort. Separa el system top-level
+ // igual que query(), pide thinking, y parsea content_block_delta → text/reasoning.
+ // Si la respuesta no es ok, tira para que chatStream caiga al fallback. No toca query().
+ async queryStream(
+ messages: CommonAgentMessage[],
+ onEvent: AgentStreamCallback
+ ): Promise {
+ const systemText = messages
+ .filter((m) => m.role === "system")
+ .map((m) => m.content)
+ .join("\n\n");
+ const chatMessages = messages
+ .filter((m) => m.role === "user" || m.role === "assistant")
+ .map((m) => ({ role: m.role, content: m.content }));
+
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
+ method: "POST",
+ headers: {
+ "x-api-key": this.token,
+ "anthropic-version": "2023-06-01",
+ "anthropic-dangerous-direct-browser-access": "true",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ model: this.model,
+ max_tokens: STREAM_MAX_TOKENS,
+ stream: true,
+ // Reasoning opt-in (best-effort). Con thinking activado, Anthropic exige
+ // temperature por defecto (=1), por eso NO la seteamos.
+ thinking: { type: "enabled", budget_tokens: THINKING_BUDGET_TOKENS },
+ ...(systemText ? { system: systemText } : {}),
+ messages: chatMessages,
+ }),
+ });
+
+ if (!response.ok || !response.body) {
+ throw new Error(await readStreamError(response, "Anthropic"));
+ }
+
+ let acc = "";
+ await readSSEStream(response.body, (data) => {
+ let event: AnthropicStreamEvent;
+ try {
+ event = JSON.parse(data) as AnthropicStreamEvent;
+ } catch {
+ return;
+ }
+
+ if (event.type === "error") {
+ throw new Error(event.error?.message ?? "Anthropic stream error");
+ }
+
+ if (event.type === "message_stop") return "stop";
+
+ if (event.type === "content_block_delta" && event.delta) {
+ if (event.delta.type === "text_delta" && event.delta.text) {
+ acc += event.delta.text;
+ onEvent({ type: "text", delta: event.delta.text });
+ } else if (
+ event.delta.type === "thinking_delta" &&
+ event.delta.thinking
+ ) {
+ onEvent({ type: "reasoning", delta: event.delta.thinking });
+ }
+ }
+ });
+
+ return acc;
+ }
+
// Lenient: devuelve el bloque SQL si existe (text-to-SQL), si no el texto (explicar).
processResult(result: string): string {
const codeBlock = result.match(/```sql\n([\s\S]*?)```/);
diff --git a/src/drivers/agent/base.ts b/src/drivers/agent/base.ts
index 4cddead..1be04ae 100644
--- a/src/drivers/agent/base.ts
+++ b/src/drivers/agent/base.ts
@@ -17,6 +17,28 @@ export interface AgentPromptResponse {
id: string;
}
+/**
+ * Eventos que emite el camino de streaming (chatStream). Es un modelo aditivo:
+ * el chat tab los consume para renderizar token a token. Hoy solo se emiten
+ * "text", "reasoning", "done" y "error"; "tool_call" queda como scaffold (la UI
+ * sabe dibujar chips si llegaran, pero NO definimos tools reales ni ejecutamos
+ * nada — se mantiene la postura del producto).
+ *
+ * - reasoning: razonamiento del modelo (Anthropic/Gemini con flag; OpenAI no lo expone).
+ * - text: texto visible del assistant (markdown, con posibles bloques ```sql).
+ * - tool_call: invocación de herramienta (display-only, scaffold).
+ * - done: el stream terminó OK.
+ * - error: el stream falló; message es legible para mostrar al usuario.
+ */
+export type AgentStreamEvent =
+ | { type: "reasoning"; delta: string }
+ | { type: "text"; delta: string }
+ | { type: "tool_call"; id: string; name: string; args?: string }
+ | { type: "done" }
+ | { type: "error"; message: string };
+
+export type AgentStreamCallback = (event: AgentStreamEvent) => void;
+
export abstract class AgentBaseDriver {
/**
*
@@ -44,4 +66,22 @@ export abstract class AgentBaseDriver {
previousId: string | undefined,
option: AgentPromptOption
): Promise;
+
+ /**
+ * Variante streaming de chat(): arma la MISMA sesión multi-turno que chat()/run()
+ * pero emite el texto token a token vía onEvent (text/reasoning/tool_call/done/error).
+ * Acumula el texto y lo persiste en el historial igual que chat(). Devuelve el texto
+ * final acumulado. NO reemplaza a chat()/run(): es un camino nuevo, aditivo.
+ *
+ * @param message User message
+ * @param previousId Previous message id. If not provided, it is a new conversation
+ * @param option
+ * @param onEvent Callback que recibe cada evento del stream
+ */
+ abstract chatStream(
+ message: string,
+ previousId: string | undefined,
+ option: AgentPromptOption,
+ onEvent: AgentStreamCallback
+ ): Promise;
}
diff --git a/src/drivers/agent/chatgpt.ts b/src/drivers/agent/chatgpt.ts
index e650d3c..5c3b8c4 100644
--- a/src/drivers/agent/chatgpt.ts
+++ b/src/drivers/agent/chatgpt.ts
@@ -1,10 +1,19 @@
import { BaseDriver } from "../base-driver";
+import { AgentStreamCallback } from "./base";
import CommonAgentDriverImplementation, { CommonAgentMessage } from "./common";
+import { readSSEStream, readStreamError } from "./sse";
interface ChatGPTResponse {
choices?: { message: { role: string; content: string } }[];
error?: { message: string };
}
+// Chunk de streaming de OpenAI (chat-completions, stream:true). Cada `data:` trae
+// un delta incremental en choices[0].delta.content. OpenAI por chat-completions NO
+// expone reasoning (solo la Responses API da un resumen), así que acá solo hay texto.
+interface ChatGPTStreamChunk {
+ choices?: { delta?: { content?: string } }[];
+}
+
export class ChatGPTDriver extends CommonAgentDriverImplementation {
constructor(
protected driver: BaseDriver,
@@ -44,4 +53,50 @@ export class ChatGPTDriver extends CommonAgentDriverImplementation {
}
return content;
}
+
+ // Streaming SSE (stream:true). Parsea las líneas `data:`, lee el delta de texto
+ // y lo emite; `data: [DONE]` cierra. Si la respuesta no es ok (sin body o {error}),
+ // tira para que chatStream caiga al query() no-streaming. NO toca query()/run().
+ async queryStream(
+ messages: CommonAgentMessage[],
+ onEvent: AgentStreamCallback
+ ): Promise {
+ const response = await fetch("https://api.openai.com/v1/chat/completions", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${this.token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ model: this.model,
+ temperature: 0,
+ stream: true,
+ messages,
+ }),
+ });
+
+ if (!response.ok || !response.body) {
+ throw new Error(await readStreamError(response, "OpenAI"));
+ }
+
+ let acc = "";
+ await readSSEStream(response.body, (data) => {
+ if (data === "[DONE]") return "stop";
+
+ let chunk: ChatGPTStreamChunk;
+ try {
+ chunk = JSON.parse(data) as ChatGPTStreamChunk;
+ } catch {
+ return; // chunk parcial/keep-alive: lo ignoramos
+ }
+
+ const delta = chunk.choices?.[0]?.delta?.content;
+ if (typeof delta === "string" && delta) {
+ acc += delta;
+ onEvent({ type: "text", delta });
+ }
+ });
+
+ return acc;
+ }
}
diff --git a/src/drivers/agent/common.ts b/src/drivers/agent/common.ts
index cdfdaa3..bb7180f 100644
--- a/src/drivers/agent/common.ts
+++ b/src/drivers/agent/common.ts
@@ -4,7 +4,7 @@ import {
DatabaseSchemas,
DatabaseTableSchema,
} from "../base-driver";
-import { AgentBaseDriver, AgentPromptOption } from "./base";
+import { AgentBaseDriver, AgentPromptOption, AgentStreamCallback } from "./base";
export interface ChatHistory {
id: string;
@@ -41,6 +41,18 @@ export default abstract class CommonAgentDriverImplementation extends AgentBaseD
abstract query(messages: CommonAgentMessage[]): Promise;
+ /**
+ * Variante streaming de query(): pega al endpoint del provider con stream
+ * activado, parsea el SSE y emite eventos (text/reasoning) vía onEvent. Devuelve
+ * el TEXTO acumulado (la fuente de verdad para persistir el historial). Debe
+ * TIRAR si el stream no arranca (HTTP no-ok, red, CORS) para que chatStream caiga
+ * al fallback no-streaming. NO toca query()/run().
+ */
+ abstract queryStream(
+ messages: CommonAgentMessage[],
+ onEvent: AgentStreamCallback
+ ): Promise;
+
getSystemContent(option: AgentPromptOption): string {
// DynamoDB no usa SQL relacional: se consulta con PartiQL. Le damos al LLM
// un prompt específico con las reglas + ejemplos canónicos, en vez del de
@@ -140,15 +152,15 @@ ${dynamoClosing}`;
throw new Error("We cannot generate good response");
}
- // Arma la sesión (system + schema + selección en el primer turno), agrega el
- // mensaje del usuario, llama al proveedor y persiste el historial. Devuelve la
- // respuesta CRUDA del assistant. Compartido por run() (Ctrl+B, text-to-SQL) y
- // chat() (tab conversacional), para que ambos usen el mismo historial multi-turno.
- private async runRaw(
+ // Recupera/crea la sesión y le agrega el turno del usuario (system + schema +
+ // selección SOLO en el primer turno). Compartido por runRaw() y chatStream()
+ // para que ambos armen el historial multi-turno EXACTAMENTE igual. Devuelve la
+ // sesión lista para mandar al proveedor (todavía sin la respuesta del assistant).
+ private prepareSession(
message: string,
previousId: string | undefined,
option: AgentPromptOption
- ): Promise {
+ ): ChatHistory {
const session = this.history[previousId ?? ""] ?? {
id: previousId || generateId(),
createdAt: Date.now(),
@@ -180,15 +192,30 @@ ${dynamoClosing}`;
content: message,
});
- const result = await this.query(session.messages);
+ return session;
+ }
- // Save the chat history
+ // Persiste la respuesta del assistant en el historial in-memory de la sesión.
+ private persistAssistant(session: ChatHistory, result: string) {
session.messages.push({
role: "assistant",
content: result,
});
this.history[session.id] = session;
+ }
+
+ // Arma la sesión, llama al proveedor (no-streaming) y persiste el historial.
+ // Devuelve la respuesta CRUDA del assistant. Compartido por run() (Ctrl+B,
+ // text-to-SQL) y chat() (tab conversacional). Comportamiento idéntico al previo.
+ private async runRaw(
+ message: string,
+ previousId: string | undefined,
+ option: AgentPromptOption
+ ): Promise {
+ const session = this.prepareSession(message, previousId, option);
+ const result = await this.query(session.messages);
+ this.persistAssistant(session, result);
return result;
}
@@ -217,6 +244,87 @@ ${dynamoClosing}`;
});
}
+ // Variante STREAMING de chat() para el chat tab. Arma la misma sesión multi-turno
+ // (conversacional) que chat(), pero delega en queryStream() para emitir el texto
+ // token a token vía onEvent. Acumula el texto y lo persiste igual que chat().
+ //
+ // Hard fallback (centralizado acá, no duplicado en cada provider): si queryStream
+ // tira SIN haber emitido TEXTO (caso típico: HTTP no-ok, CORS, red, un modelo que
+ // rechaza el flag de reasoning, o un error SSE que llega DESPUÉS del reasoning pero
+ // ANTES del texto), caemos al query() no-streaming y emitimos un único evento "text"
+ // con toda la respuesta. La decisión gatea por TEXTO real mostrado al usuario, NO
+ // por reasoning: el reasoning va a un bloque aparte y no es "la respuesta", así que
+ // si solo hubo reasoning todavía es seguro (y deseable) reintentar por el camino
+ // no-streaming. Si ya salió texto y después explota, NO rehacemos (duplicaría):
+ // cerramos con un "error". Nunca tira: siempre resuelve emitiendo "done", para que
+ // la UI event-driven cierre el turno.
+ async chatStream(
+ message: string,
+ previousId: string | undefined,
+ option: AgentPromptOption,
+ onEvent: AgentStreamCallback
+ ): Promise {
+ const session = this.prepareSession(message, previousId, {
+ ...option,
+ conversational: true,
+ });
+
+ // Acumulamos el texto VISIBLE para: (a) persistir el historial y (b) decidir si el
+ // hard fallback es seguro. El reasoning NO se acumula acá a propósito (no es la
+ // respuesta y no debe inhibir el fallback).
+ let text = "";
+
+ const wrapped: AgentStreamCallback = (event) => {
+ if (event.type === "text") {
+ text += event.delta;
+ }
+ onEvent(event);
+ };
+
+ try {
+ const finalText = await this.queryStream(session.messages, wrapped);
+ // queryStream devuelve el texto acumulado (fuente de verdad). Si por algún
+ // motivo no acumulamos vía eventos, usamos su retorno.
+ if (finalText) text = finalText;
+ this.persistAssistant(session, text);
+ onEvent({ type: "done" });
+ return text;
+ } catch (streamError) {
+ if (text) {
+ // Ya mostramos TEXTO real: no podemos rehacer la respuesta sin duplicarla.
+ onEvent({
+ type: "error",
+ message:
+ streamError instanceof Error
+ ? streamError.message
+ : "El stream se interrumpió",
+ });
+ this.persistAssistant(session, text);
+ onEvent({ type: "done" });
+ return text;
+ }
+
+ // No salió texto (a lo sumo reasoning) → fallback duro al camino no-streaming.
+ try {
+ text = await this.query(session.messages);
+ this.persistAssistant(session, text);
+ onEvent({ type: "text", delta: text });
+ onEvent({ type: "done" });
+ return text;
+ } catch (fallbackError) {
+ onEvent({
+ type: "error",
+ message:
+ fallbackError instanceof Error
+ ? fallbackError.message
+ : "No se pudo generar la respuesta",
+ });
+ onEvent({ type: "done" });
+ return "";
+ }
+ }
+ }
+
constructor(protected driver: BaseDriver) {
super();
}
diff --git a/src/drivers/agent/gemini.ts b/src/drivers/agent/gemini.ts
index e739134..5c6f53c 100644
--- a/src/drivers/agent/gemini.ts
+++ b/src/drivers/agent/gemini.ts
@@ -1,11 +1,25 @@
import { BaseDriver } from "../base-driver";
+import { AgentStreamCallback } from "./base";
import CommonAgentDriverImplementation, { CommonAgentMessage } from "./common";
+import { readSSEStream, readStreamError } from "./sse";
interface GeminiResponse {
candidates?: { content: { parts: { text?: string }[] } }[];
error?: { message: string };
}
+// Chunk de streaming de Gemini (:streamGenerateContent?alt=sse). Cada `data:` trae
+// candidates con parts incrementales. Una part con thought:true es reasoning; el
+// resto es texto. NO forzamos includeThoughts (rompería el streaming del default
+// gemini-2.0-flash, que no es modelo "thinking"): si el modelo elegido emite
+// thoughts, los mostramos; si no, no inventamos.
+interface GeminiStreamChunk {
+ candidates?: {
+ content?: { parts?: { text?: string; thought?: boolean }[] };
+ }[];
+ error?: { message?: string };
+}
+
/**
* Driver de agente para Google Gemini. Default: gemini-2.0-flash.
* La key se pasa por constructor (localStorage). Gemini usa `?key=` y
@@ -57,6 +71,72 @@ export class GeminiDriver extends CommonAgentDriverImplementation {
return text;
}
+ // Streaming SSE (:streamGenerateContent?alt=sse). Arma el body igual que query()
+ // y parsea cada chunk: parts con thought:true → reasoning, resto → texto. Si la
+ // respuesta no es ok, tira para que chatStream caiga al fallback. No toca query().
+ async queryStream(
+ messages: CommonAgentMessage[],
+ onEvent: AgentStreamCallback
+ ): Promise {
+ const systemText = messages
+ .filter((m) => m.role === "system")
+ .map((m) => m.content)
+ .join("\n\n");
+
+ const contents = messages
+ .filter((m) => m.role === "user" || m.role === "assistant")
+ .map((m) => ({
+ role: m.role === "assistant" ? "model" : "user",
+ parts: [{ text: m.content }],
+ }));
+
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${this.model}:streamGenerateContent?alt=sse&key=${encodeURIComponent(
+ this.token
+ )}`;
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ ...(systemText
+ ? { systemInstruction: { parts: [{ text: systemText }] } }
+ : {}),
+ contents,
+ }),
+ });
+
+ if (!response.ok || !response.body) {
+ throw new Error(await readStreamError(response, "Gemini"));
+ }
+
+ let acc = "";
+ await readSSEStream(response.body, (data) => {
+ let chunk: GeminiStreamChunk;
+ try {
+ chunk = JSON.parse(data) as GeminiStreamChunk;
+ } catch {
+ return;
+ }
+
+ if (chunk.error) {
+ throw new Error(chunk.error.message ?? "Gemini stream error");
+ }
+
+ const parts = chunk.candidates?.[0]?.content?.parts ?? [];
+ for (const part of parts) {
+ if (!part.text) continue;
+ if (part.thought) {
+ onEvent({ type: "reasoning", delta: part.text });
+ } else {
+ acc += part.text;
+ onEvent({ type: "text", delta: part.text });
+ }
+ }
+ });
+
+ return acc;
+ }
+
processResult(result: string): string {
const codeBlock = result.match(/```sql\n([\s\S]*?)```/);
if (codeBlock) return codeBlock[1];
diff --git a/src/drivers/agent/list.tsx b/src/drivers/agent/list.tsx
index 2de52c7..97311fb 100644
--- a/src/drivers/agent/list.tsx
+++ b/src/drivers/agent/list.tsx
@@ -1,6 +1,6 @@
import { ReactElement } from "react";
import { BaseDriver } from "../base-driver";
-import { AgentBaseDriver, AgentPromptOption } from "./base";
+import { AgentBaseDriver, AgentPromptOption, AgentStreamCallback } from "./base";
import { AnthropicDriver } from "./anthropic";
import { ChatGPTDriver } from "./chatgpt";
// DEPRECATED: cloudflare — agent free-tier de Cloudflare Workers AI sacado del build.
@@ -167,4 +167,23 @@ export default class AgentDriverList {
return await driver.chat(message, sessionId, options);
}
+
+ // Variante STREAMING para el chat tab: misma resolución que chat() pero delega en
+ // driver.chatStream(), que emite el texto token a token vía onEvent. Aditivo: no
+ // toca run()/chat().
+ async chatStream(
+ modelName: string,
+ message: string,
+ sessionId: string | undefined,
+ options: AgentPromptOption,
+ onEvent: AgentStreamCallback
+ ): Promise {
+ const driver = this.resolveDriver(modelName);
+
+ if (!driver) {
+ throw new Error(`Selected model ${modelName} is not available`);
+ }
+
+ return await driver.chatStream(message, sessionId, options, onEvent);
+ }
}
diff --git a/src/drivers/agent/sse.ts b/src/drivers/agent/sse.ts
new file mode 100644
index 0000000..9719b4a
--- /dev/null
+++ b/src/drivers/agent/sse.ts
@@ -0,0 +1,104 @@
+// Parser mínimo de Server-Sent Events sobre un ReadableStream de fetch.
+// Lo usan los queryStream() de cada provider (OpenAI/Anthropic/Gemini): los tres
+// mandan payloads de una sola línea `data: ...` por evento, así que alcanza con
+// ir cortando por '\n' y procesar las líneas `data:` completas. No metemos
+// dependencias nuevas (la consigna es no sumar libs): TextDecoder + getReader().
+
+export type SSEDataHandler = (
+ data: string
+) => void | "stop" | Promise;
+
+/**
+ * Lee el stream SSE y llama a onData con el contenido de cada línea `data:`
+ * (ya sin el prefijo). Ignora líneas `event:`, comentarios `:` y líneas vacías.
+ * Si onData devuelve "stop" (p.ej. al ver `[DONE]`), corta y libera el reader.
+ *
+ * Es resiliente a chunks partidos: acumula en un buffer y solo procesa hasta el
+ * último '\n' completo; lo que queda sin newline espera al próximo chunk.
+ */
+export async function readSSEStream(
+ stream: ReadableStream,
+ onData: SSEDataHandler
+): Promise {
+ const reader = stream.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ try {
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+
+ let nlIndex: number;
+ while ((nlIndex = buffer.indexOf("\n")) !== -1) {
+ const rawLine = buffer.slice(0, nlIndex);
+ buffer = buffer.slice(nlIndex + 1);
+
+ const signal = await handleLine(rawLine, onData);
+ if (signal === "stop") return;
+ }
+ }
+
+ // Flush final: por si el stream cerró sin un '\n' al final de la última línea.
+ if (buffer.length > 0) {
+ await handleLine(buffer, onData);
+ }
+ } catch (err) {
+ // Path de error (p.ej. el callback tira ante un evento "error" del provider):
+ // cancelamos el body para no dejar la conexión colgada antes de re-lanzar.
+ try {
+ await reader.cancel();
+ } catch {
+ // el stream ya pudo haberse cerrado: lo ignoramos
+ }
+ throw err;
+ } finally {
+ reader.releaseLock();
+ }
+}
+
+/**
+ * Lee el cuerpo de error de una respuesta de streaming fallida y devuelve un
+ * mensaje legible. Los providers mandan el error como JSON {error:{message}} antes
+ * de abrir el stream; si no se puede leer, cae al status HTTP. Compartido por los
+ * queryStream() de los tres providers.
+ */
+export async function readStreamError(
+ response: Response,
+ provider: string
+): Promise {
+ try {
+ const raw = await response.text();
+ if (raw) {
+ try {
+ const json = JSON.parse(raw);
+ const message = json?.error?.message ?? json?.message;
+ if (message) return message;
+ } catch {
+ // no era JSON: devolvemos el texto crudo recortado
+ return raw.slice(0, 300);
+ }
+ }
+ } catch {
+ // ignoramos y caemos al status
+ }
+ return `${provider} respondió ${response.status} ${response.statusText}`.trim();
+}
+
+async function handleLine(
+ rawLine: string,
+ onData: SSEDataHandler
+): Promise {
+ // Normalizamos CRLF y espacios al final de línea.
+ const line = rawLine.replace(/\r$/, "");
+ if (!line.startsWith("data:")) return; // ignoramos event:/comentarios/vacías
+
+ // `data:foo` y `data: foo` son equivalentes en SSE: el primer espacio opcional
+ // tras los dos puntos no es parte del dato.
+ const data = line.slice(5).replace(/^ /, "");
+ if (!data) return;
+
+ return await onData(data);
+}
diff --git a/src/lib/agent-models.test.ts b/src/lib/agent-models.test.ts
new file mode 100644
index 0000000..98e2a94
--- /dev/null
+++ b/src/lib/agent-models.test.ts
@@ -0,0 +1,159 @@
+import {
+ CURATED_MODELS,
+ extractGeminiModels,
+ fetchProviderModels,
+ filterOpenAIChatModels,
+} from "./agent-models";
+
+// Mock de fetch que devuelve una Response con ok:true y json() = payload.
+const okResponse = (payload: unknown) =>
+ jest.fn().mockResolvedValue({
+ ok: true,
+ json: async () => payload,
+ } as Response);
+
+// Mock de fetch que devuelve ok:false (HTTP error → debe caer al fallback curado).
+const errorResponse = (status = 403) =>
+ jest.fn().mockResolvedValue({
+ ok: false,
+ status,
+ statusText: "Forbidden",
+ json: async () => ({ error: { message: "nope" } }),
+ } as Response);
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+describe("filterOpenAIChatModels — solo chat models", () => {
+ test("incluye gpt* y o*, ordenados", () => {
+ const out = filterOpenAIChatModels([
+ "gpt-4o",
+ "gpt-4o-mini",
+ "o3",
+ "o4-mini",
+ ]);
+ expect(out).toEqual(["gpt-4o", "gpt-4o-mini", "o3", "o4-mini"]);
+ });
+
+ test("excluye embeddings, audio, imágenes, moderación, instruct", () => {
+ const out = filterOpenAIChatModels([
+ "gpt-4o",
+ "text-embedding-3-small",
+ "whisper-1",
+ "dall-e-3",
+ "tts-1",
+ "omni-moderation-latest",
+ "gpt-3.5-turbo-instruct",
+ "gpt-4o-audio-preview",
+ "gpt-4o-realtime-preview",
+ ]);
+ expect(out).toEqual(["gpt-4o"]);
+ });
+
+ test("descarta ids vacíos", () => {
+ expect(filterOpenAIChatModels(["", "gpt-4o"])).toEqual(["gpt-4o"]);
+ });
+});
+
+describe("extractGeminiModels — filtra generateContent + limpia prefijo", () => {
+ test("solo modelos con generateContent, sin prefijo models/", () => {
+ const out = extractGeminiModels([
+ {
+ name: "models/gemini-2.0-flash",
+ supportedGenerationMethods: ["generateContent", "countTokens"],
+ },
+ {
+ name: "models/embedding-001",
+ supportedGenerationMethods: ["embedContent"],
+ },
+ {
+ name: "models/gemini-1.5-pro",
+ supportedGenerationMethods: ["generateContent"],
+ },
+ ]);
+ expect(out).toEqual(["gemini-1.5-pro", "gemini-2.0-flash"]);
+ });
+});
+
+describe("fetchProviderModels — live + fallback curado", () => {
+ test("openai: parsea data[].id y filtra a chat models", async () => {
+ global.fetch = okResponse({
+ data: [
+ { id: "gpt-4o" },
+ { id: "o3" },
+ { id: "text-embedding-3-large" },
+ ],
+ }) as unknown as typeof fetch;
+
+ const res = await fetchProviderModels("openai", "sk-test");
+ expect(res.fromFallback).toBe(false);
+ expect(res.models).toContain("gpt-4o");
+ expect(res.models).toContain("o3");
+ expect(res.models).not.toContain("text-embedding-3-large");
+ });
+
+ test("anthropic: parsea data[].id y ordena", async () => {
+ const fetchSpy = okResponse({
+ data: [{ id: "claude-sonnet-4-6" }, { id: "claude-opus-4-8" }],
+ });
+ global.fetch = fetchSpy as unknown as typeof fetch;
+
+ const res = await fetchProviderModels("anthropic", "sk-ant");
+ expect(res.fromFallback).toBe(false);
+ expect(res.models).toEqual(["claude-opus-4-8", "claude-sonnet-4-6"]);
+
+ // Manda la API key por x-api-key + el header de browser access.
+ const headers = (fetchSpy.mock.calls[0][1] as RequestInit)
+ .headers as Record;
+ expect(headers["x-api-key"]).toBe("sk-ant");
+ expect(headers["anthropic-dangerous-direct-browser-access"]).toBe("true");
+ });
+
+ test("gemini: filtra generateContent y limpia models/", async () => {
+ global.fetch = okResponse({
+ models: [
+ {
+ name: "models/gemini-2.0-flash",
+ supportedGenerationMethods: ["generateContent"],
+ },
+ {
+ name: "models/embedding-001",
+ supportedGenerationMethods: ["embedContent"],
+ },
+ ],
+ }) as unknown as typeof fetch;
+
+ const res = await fetchProviderModels("gemini", "g-key");
+ expect(res.fromFallback).toBe(false);
+ expect(res.models).toEqual(["gemini-2.0-flash"]);
+ });
+
+ test("HTTP error → cae a la lista curada con fromFallback:true", async () => {
+ global.fetch = errorResponse(403) as unknown as typeof fetch;
+
+ const res = await fetchProviderModels("openai", "sk-bad");
+ expect(res.fromFallback).toBe(true);
+ expect(res.models).toEqual(CURATED_MODELS.openai);
+ });
+
+ test("fetch que rechaza (red/CORS) → fallback curado", async () => {
+ global.fetch = jest
+ .fn()
+ .mockRejectedValue(new Error("Failed to fetch")) as unknown as typeof fetch;
+
+ const res = await fetchProviderModels("gemini", "g-key");
+ expect(res.fromFallback).toBe(true);
+ expect(res.models).toEqual(CURATED_MODELS.gemini);
+ });
+
+ test("sin token → fallback curado, sin pegarle a la red", async () => {
+ const fetchSpy = jest.fn();
+ global.fetch = fetchSpy as unknown as typeof fetch;
+
+ const res = await fetchProviderModels("anthropic", "");
+ expect(res.fromFallback).toBe(true);
+ expect(res.models).toEqual(CURATED_MODELS.anthropic);
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/agent-models.ts b/src/lib/agent-models.ts
new file mode 100644
index 0000000..f17cd8c
--- /dev/null
+++ b/src/lib/agent-models.ts
@@ -0,0 +1,165 @@
+import { AgentProvider } from "@/lib/ai-agent-storage";
+
+// Fetch de la lista de modelos por provider, pegándole al endpoint `/models` con
+// la API key del usuario. Si falla (CORS, permiso, offline, key inválida) caemos a
+// una lista CURADA hardcodeada por provider — así el picker siempre muestra algo.
+
+export interface ProviderModelsResult {
+ models: string[];
+ // true cuando la lista viene del fallback curado (no del fetch live). La UI lo
+ // usa para mostrar el badge "lista offline".
+ fromFallback: boolean;
+}
+
+// Listas curadas (fallback). Pensadas para ser "lo razonable hoy"; si el fetch live
+// anda, esto ni se usa. No pretende ser exhaustivo: es un piso usable sin red.
+export const CURATED_MODELS: Record = {
+ anthropic: [
+ "claude-opus-4-8",
+ "claude-sonnet-4-6",
+ "claude-haiku-4-5-20251001",
+ "claude-opus-4-1-20250805",
+ "claude-3-7-sonnet-latest",
+ "claude-3-5-haiku-latest",
+ ],
+ openai: [
+ "gpt-4o",
+ "gpt-4o-mini",
+ "gpt-4.1",
+ "gpt-4.1-mini",
+ "o3",
+ "o4-mini",
+ ],
+ gemini: [
+ "gemini-2.5-pro",
+ "gemini-2.5-flash",
+ "gemini-2.0-flash",
+ "gemini-1.5-pro",
+ "gemini-1.5-flash",
+ ],
+};
+
+// Subcadenas que delatan modelos NO-chat de OpenAI (embeddings, audio, imágenes,
+// moderación, etc.). El endpoint /models los mezcla todos; filtramos a chat.
+const OPENAI_NON_CHAT = [
+ "instruct",
+ "embedding",
+ "whisper",
+ "tts",
+ "audio",
+ "realtime",
+ "transcribe",
+ "image",
+ "dall-e",
+ "moderation",
+ "search",
+ "davinci",
+ "babbage",
+];
+
+// Filtra los ids de OpenAI a chat models: arrancan con "gpt" o con "o" + dígito
+// (o1/o3/o4…), y NO matchean ninguna subcadena no-chat. Ordena alfabéticamente.
+// Exportada para testear el parseo sin red.
+export function filterOpenAIChatModels(ids: string[]): string[] {
+ return ids
+ .filter((id) => {
+ if (!id) return false;
+ const isChat = id.startsWith("gpt") || /^o\d/.test(id);
+ if (!isChat) return false;
+ return !OPENAI_NON_CHAT.some((bad) => id.includes(bad));
+ })
+ .sort();
+}
+
+// Extrae los modelos de Gemini que soportan generateContent y limpia el prefijo
+// "models/". Exportada para testear el parseo sin red.
+export function extractGeminiModels(
+ models: { name?: string; supportedGenerationMethods?: string[] }[]
+): string[] {
+ return models
+ .filter((m) =>
+ (m.supportedGenerationMethods ?? []).includes("generateContent")
+ )
+ .map((m) => (m.name ?? "").replace(/^models\//, ""))
+ .filter(Boolean)
+ .sort();
+}
+
+async function fetchOpenAIModels(token: string): Promise {
+ const response = await fetch("https://api.openai.com/v1/models", {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!response.ok) throw new Error(`OpenAI /models ${response.status}`);
+
+ const json = (await response.json()) as {
+ data?: { id?: string }[];
+ };
+ const ids = (json.data ?? []).map((m) => m.id ?? "").filter(Boolean);
+ const filtered = filterOpenAIChatModels(ids);
+ if (filtered.length === 0) throw new Error("OpenAI /models sin chat models");
+ return filtered;
+}
+
+async function fetchAnthropicModels(token: string): Promise {
+ const response = await fetch("https://api.anthropic.com/v1/models", {
+ headers: {
+ "x-api-key": token,
+ "anthropic-version": "2023-06-01",
+ "anthropic-dangerous-direct-browser-access": "true",
+ },
+ });
+ if (!response.ok) throw new Error(`Anthropic /models ${response.status}`);
+
+ const json = (await response.json()) as {
+ data?: { id?: string }[];
+ };
+ const ids = (json.data ?? []).map((m) => m.id ?? "").filter(Boolean);
+ if (ids.length === 0) throw new Error("Anthropic /models vacío");
+ return ids.sort();
+}
+
+async function fetchGeminiModels(token: string): Promise {
+ const response = await fetch(
+ `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(
+ token
+ )}`
+ );
+ if (!response.ok) throw new Error(`Gemini /models ${response.status}`);
+
+ const json = (await response.json()) as {
+ models?: { name?: string; supportedGenerationMethods?: string[] }[];
+ };
+ const models = extractGeminiModels(json.models ?? []);
+ if (models.length === 0) throw new Error("Gemini /models sin generateContent");
+ return models;
+}
+
+// Pega al /models del provider con la key; ante CUALQUIER fallo cae a la lista
+// curada con fromFallback:true. Nunca tira: el picker siempre recibe modelos.
+export async function fetchProviderModels(
+ provider: AgentProvider,
+ token: string
+): Promise {
+ try {
+ if (!token) throw new Error("sin token");
+
+ let models: string[];
+ switch (provider) {
+ case "openai":
+ models = await fetchOpenAIModels(token);
+ break;
+ case "anthropic":
+ models = await fetchAnthropicModels(token);
+ break;
+ case "gemini":
+ models = await fetchGeminiModels(token);
+ break;
+ default:
+ throw new Error(`provider desconocido: ${provider}`);
+ }
+
+ return { models, fromFallback: false };
+ } catch {
+ return { models: CURATED_MODELS[provider], fromFallback: true };
+ }
+}
diff --git a/src/lib/ai-agent-storage.test.ts b/src/lib/ai-agent-storage.test.ts
new file mode 100644
index 0000000..e24b742
--- /dev/null
+++ b/src/lib/ai-agent-storage.test.ts
@@ -0,0 +1,137 @@
+import {
+ DEFAULT_MODEL_BY_PROVIDER,
+ getAgentFromLocalStorage,
+ mergeAgentConfig,
+ patchAgentConfig,
+} from "./ai-agent-storage";
+
+// Mock mínimo de localStorage (jest corre en node, no hay window.localStorage real).
+function mockLocalStorage() {
+ const store: Record = {};
+ return {
+ getItem: (k: string) => (k in store ? store[k] : null),
+ setItem: (k: string, v: string) => {
+ store[k] = v;
+ },
+ removeItem: (k: string) => {
+ delete store[k];
+ },
+ clear: () => {
+ for (const k of Object.keys(store)) delete store[k];
+ },
+ };
+}
+
+beforeEach(() => {
+ (global as unknown as { localStorage: Storage }).localStorage =
+ mockLocalStorage() as unknown as Storage;
+});
+
+describe("mergeAgentConfig — merge parcial puro", () => {
+ test("solo model: mantiene provider y token", () => {
+ const out = mergeAgentConfig(
+ { provider: "anthropic", model: "claude-opus-4-8", token: "k" },
+ { model: "claude-sonnet-4-6" }
+ );
+ expect(out).toEqual({
+ provider: "anthropic",
+ model: "claude-sonnet-4-6",
+ token: "k",
+ });
+ });
+
+ test("cambio de provider sin model: cae al default del nuevo provider, mantiene token", () => {
+ const out = mergeAgentConfig(
+ { provider: "anthropic", model: "claude-opus-4-8", token: "k" },
+ { provider: "openai" }
+ );
+ expect(out).toEqual({
+ provider: "openai",
+ model: DEFAULT_MODEL_BY_PROVIDER.openai,
+ token: "k",
+ });
+ });
+
+ test("sin current: defaults (anthropic, token vacío) con el partial encima", () => {
+ const out = mergeAgentConfig(undefined, { model: "x" });
+ expect(out).toEqual({ provider: "anthropic", model: "x", token: "" });
+ });
+
+ test("token:'' explícito limpia la key (cambio de provider la invalida)", () => {
+ // El provider-picker manda token:'' al cambiar de provider: la key vieja es de
+ // otro provider. mergeAgentConfig debe respetar el '' y NO recuperar el viejo.
+ const out = mergeAgentConfig(
+ { provider: "anthropic", model: "claude-opus-4-8", token: "sk-ant" },
+ { provider: "openai", model: DEFAULT_MODEL_BY_PROVIDER.openai, token: "" }
+ );
+ expect(out).toEqual({
+ provider: "openai",
+ model: DEFAULT_MODEL_BY_PROVIDER.openai,
+ token: "",
+ });
+ });
+});
+
+describe("getAgentFromLocalStorage — parseo defensivo", () => {
+ test("config válida → la devuelve", () => {
+ localStorage.setItem(
+ "agent",
+ JSON.stringify({ provider: "openai", model: "gpt-4o", token: "sk" })
+ );
+ expect(getAgentFromLocalStorage()).toEqual({
+ provider: "openai",
+ model: "gpt-4o",
+ token: "sk",
+ });
+ });
+
+ test("provider inválido → undefined", () => {
+ localStorage.setItem(
+ "agent",
+ JSON.stringify({ provider: "cohere", model: "x", token: "sk" })
+ );
+ expect(getAgentFromLocalStorage()).toBeUndefined();
+ });
+
+ test("sin token → undefined", () => {
+ localStorage.setItem(
+ "agent",
+ JSON.stringify({ provider: "openai", model: "gpt-4o" })
+ );
+ expect(getAgentFromLocalStorage()).toBeUndefined();
+ });
+
+ test("sin model → usa el default del provider", () => {
+ localStorage.setItem(
+ "agent",
+ JSON.stringify({ provider: "gemini", token: "g" })
+ );
+ expect(getAgentFromLocalStorage()?.model).toBe(
+ DEFAULT_MODEL_BY_PROVIDER.gemini
+ );
+ });
+
+ test("JSON inválido → undefined", () => {
+ localStorage.setItem("agent", "{ no es json");
+ expect(getAgentFromLocalStorage()).toBeUndefined();
+ });
+});
+
+describe("patchAgentConfig — merge + persistencia", () => {
+ test("patch de model sobre config guardada: persiste el merge y lo devuelve", () => {
+ localStorage.setItem(
+ "agent",
+ JSON.stringify({ provider: "anthropic", model: "claude-opus-4-8", token: "k" })
+ );
+
+ const out = patchAgentConfig({ model: "claude-sonnet-4-6" });
+ const merged = {
+ provider: "anthropic",
+ model: "claude-sonnet-4-6",
+ token: "k",
+ };
+
+ expect(out).toEqual(merged);
+ expect(JSON.parse(localStorage.getItem("agent") as string)).toEqual(merged);
+ });
+});
diff --git a/src/lib/ai-agent-storage.ts b/src/lib/ai-agent-storage.ts
index 08e05fa..24fb7b1 100644
--- a/src/lib/ai-agent-storage.ts
+++ b/src/lib/ai-agent-storage.ts
@@ -51,6 +51,39 @@ export function updateAgentFromLocalStorage(data: LocalAgentType) {
mutate("/local-agent-setting", data);
}
+// Merge puro (sin side-effects) de una config parcial sobre la actual. Si falta el
+// model y cambió el provider, cae al default de ESE provider. Usado por los dialogs
+// de provider/model para no perder los campos que no tocan. Testeable sin localStorage.
+export function mergeAgentConfig(
+ current: LocalAgentType | undefined,
+ partial: Partial
+): LocalAgentType {
+ const provider = partial.provider ?? current?.provider ?? "anthropic";
+ const token = partial.token ?? current?.token ?? "";
+
+ // Si cambió el provider y no vino un model explícito, reseteamos al default de ESE
+ // provider — mantener el model del provider anterior no tendría sentido (p.ej. un
+ // "gpt-4o" colgando en una config de anthropic).
+ const providerChanged =
+ partial.provider != null && partial.provider !== current?.provider;
+ const model =
+ partial.model ??
+ (providerChanged ? DEFAULT_MODEL_BY_PROVIDER[provider] : current?.model) ??
+ DEFAULT_MODEL_BY_PROVIDER[provider];
+
+ return { provider, model, token };
+}
+
+// Aplica un update PARCIAL de la config (merge sobre lo guardado + persiste + mutate).
+// Lo usan model-picker (solo model) y provider-picker (provider + model default).
+export function patchAgentConfig(
+ partial: Partial
+): LocalAgentType {
+ const merged = mergeAgentConfig(getAgentFromLocalStorage(), partial);
+ updateAgentFromLocalStorage(merged);
+ return merged;
+}
+
export function useAvailableAIAgents(databaseDriver?: BaseDriver | null) {
const { data: agentConfig } = useSWR(
"/local-agent-setting",