fix: preserve model reasoning when visible response is empty

When a model generates only a reasoning block (<think>...</think>) with no visible content, the reasoning was previously lost because the DB insert omitted the reasoning_content column while using a fallback error message.

This separates the hasReasoningOnly condition from the corrupted/transcript leakage conditions, ensuring the reasoning is properly saved and the user sees a specific placeholder.
This commit is contained in:
Evandro Fonseca Junior committed 2026-04-03 22:49:46 -03:00
1 parent 8b9deb5008
commit 943bf6321f
2 files changed
+828 -413

No files matched your search

+214 -5
View File
@@ -3,6 +3,7 @@
import { createRoot, type Root } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parseAssistantResponse } from "../lib/chat";
import type { DB } from "../lib/db";
import ChatView from "./ChatView";
import type { Message } from "../lib/types";
@@ -26,7 +27,7 @@ const defaultChatViewProps = {
status: "error" as import("../lib/types").ServerStatus,
};
describe("ChatView", () => {
describe.sequential("ChatView", () => {
let container: HTMLDivElement;
let root: Root;
@@ -43,6 +44,7 @@ describe("ChatView", () => {
act(() => root.unmount());
container.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
});
@@ -78,7 +80,7 @@ describe("ChatView", () => {
});
expect(textarea?.value).toBe("Keep this draft");
expect(container.textContent).toContain("Runtime offline");
expect(container.textContent).toContain("Connect runtime");
expect(vi.mocked(db.run)).not.toHaveBeenCalled();
});
@@ -251,7 +253,7 @@ describe("ChatView", () => {
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
});
const sendButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Send"));
const sendButton = container.querySelector('[aria-label="Send message"]') as HTMLButtonElement | null;
expect(sendButton).toBeTruthy();
await act(async () => {
@@ -295,6 +297,115 @@ describe("ChatView", () => {
expect(container.textContent).not.toContain("Late answer from first chat");
});
it("sanitizes leaked assistant transcript before sending history back to the model", async () => {
// The DB now stores already-cleaned content (leakage is stripped before insert).
// buildApiMessages trusts the DB content and only drops fully-corrupted rows.
// Messages that were fully leakage (no useful prefix) are saved as a ⚠️ placeholder
// and excluded by the NOT LIKE '⚠️%' filter, so they never reach the model.
const rowsByChat = new Map<number, Message[]>([
[1, [
{ id: 11, chat_id: 1, role: "user", content: "Mensagem anterior", created_at: 1 },
{
id: 12,
chat_id: 1,
role: "assistant",
// Simulates what is stored AFTER leakage strip: only the useful prefix.
content: "Resposta útil.",
created_at: 2,
},
// id: 13 was fully corrupted (no useful prefix), saved as ⚠️ placeholder.
// It is excluded by the NOT LIKE '⚠️%' filter in buildApiMessages.
]],
]);
const db = {
insert: vi.fn(() => 1),
query: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("SELECT role, content FROM messages WHERE chat_id = ?")) {
return [...(rowsByChat.get(Number(params?.[0])) ?? [])].map(({ role, content }) => ({ role, content }));
}
if (sql.includes("FROM messages WHERE chat_id = ?")) {
return [...(rowsByChat.get(Number(params?.[0])) ?? [])];
}
return [];
}),
get: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("COUNT(*) as c FROM messages WHERE chat_id = ?")) {
return { c: rowsByChat.get(Number(params?.[0]))?.length ?? 0 };
}
return null;
}),
run: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)")) {
const chatId = Number(params?.[0]);
const content = String(params?.[1] ?? "");
const next = rowsByChat.get(chatId) ?? [];
next.push({ id: 99, chat_id: chatId, role: "user", content, created_at: 99 });
rowsByChat.set(chatId, next);
}
}),
persist: vi.fn(),
} as unknown as DB;
const fetchSpy = vi.fn(() =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("data: [DONE]\n"));
controller.close();
},
}),
{ status: 200, headers: { "Content-Type": "text/event-stream" } },
),
),
);
vi.stubGlobal("fetch", fetchSpy);
await act(async () => {
root.render(
<ChatView
db={db}
chatId={1}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
{...defaultChatViewProps}
/>,
);
});
const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null;
expect(textarea).toBeTruthy();
const setTextareaValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
await act(async () => {
setTextareaValue?.call(textarea, "Nova pergunta");
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
});
const sendButton = container.querySelector('[aria-label="Send message"]') as HTMLButtonElement | null;
await act(async () => {
sendButton?.click();
await Promise.resolve();
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
const call = fetchSpy.mock.calls[0] as unknown as [string, RequestInit];
const [, init] = call;
const body = JSON.parse(String(init.body));
// System prompt is always prepended as the first message
expect(body.messages[0].role).toBe("system");
expect(body.messages.slice(1)).toEqual([
{ role: "user", content: "Nova pergunta" },
{ role: "assistant", content: "Resposta útil." },
{ role: "user", content: "Mensagem anterior" },
]);
});
it("shows assistant reply after generation completes in an existing chat", async () => {
const rowsByChat = new Map<number, Message[]>([
[1, [{ id: 11, chat_id: 1, role: "user", content: "Previous message", created_at: 1 }]],
@@ -374,7 +485,7 @@ describe("ChatView", () => {
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
});
const sendButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Send"));
const sendButton = container.querySelector('[aria-label="Send message"]') as HTMLButtonElement | null;
await act(async () => { sendButton?.click(); });
// During generation, user message should be visible
@@ -446,7 +557,7 @@ describe("ChatView", () => {
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
});
const sendButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Send"));
const sendButton = container.querySelector('[aria-label="Send message"]') as HTMLButtonElement | null;
expect(sendButton).toBeTruthy();
await act(async () => {
@@ -478,4 +589,102 @@ describe("ChatView", () => {
expect(requestSignal.aborted).toBe(true);
});
it("derives visible content and reasoning from think blocks before persistence", () => {
const parsed = parseAssistantResponse("<think>analise interna</think>Resposta util final");
expect(parsed.content).toBe("Resposta util final");
expect(parsed.reasoning).toBe("analise interna");
expect(parsed.thinkingOpen).toBe(false);
});
it("stores a controlled placeholder when the model starts inside a leaked code fence transcript", async () => {
const rowsByChat = new Map<number, Message[]>([[1, []]]);
let nextMessageId = 1;
const db = {
insert: vi.fn(() => 1),
query: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("FROM messages WHERE chat_id = ?")) {
return [...(rowsByChat.get(Number(params?.[0])) ?? [])];
}
return [];
}),
get: vi.fn(() => null),
run: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)")) {
const chatId = Number(params?.[0]);
const content = String(params?.[1] ?? "");
const next = rowsByChat.get(chatId) ?? [];
next.push({ id: nextMessageId++, chat_id: chatId, role: "user", content, created_at: nextMessageId });
rowsByChat.set(chatId, next);
}
if (sql.includes("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)")) {
const chatId = Number(params?.[0]);
const content = String(params?.[1] ?? "");
const next = rowsByChat.get(chatId) ?? [];
next.push({ id: nextMessageId++, chat_id: chatId, role: "assistant", content, created_at: nextMessageId });
rowsByChat.set(chatId, next);
}
}),
persist: vi.fn(),
} as unknown as DB;
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
controller.enqueue(encoder.encode("data: {\"choices\":[{\"delta\":{\"content\":\"\\n\\n```python\\n# System: You are a precise coding assistant running locally.\\nUser: teste mensagem\\nAssistant: ```\"}}]}\n"));
controller.enqueue(encoder.encode("data: [DONE]\n"));
controller.close();
},
}),
{ status: 200, headers: { "Content-Type": "text/event-stream" } },
),
),
),
);
await act(async () => {
root.render(
<ChatView
db={db}
chatId={1}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
{...defaultChatViewProps}
/>,
);
});
const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null;
const sendButton = container.querySelector('[aria-label="Send message"]') as HTMLButtonElement | null;
const setTextareaValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
await act(async () => {
setTextareaValue?.call(textarea, "teste mensagem");
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
sendButton?.click();
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
});
const runCalls = vi.mocked(db.run).mock.calls as Array<[string, unknown[]?]>;
expect(
runCalls.some(
([sql, params]) =>
sql === "INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)"
&& Array.isArray(params)
&& params[0] === 1
&& params[1] === "⚠️ O modelo gerou uma resposta corrompida ou vazou o prompt de sistema.",
),
).toBe(true);
});
});
File diff suppressed because it is too large. Load diff