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:
1 parent
8b9deb5008
commit
943bf6321f
2 files changed
+828
-413
No files matched your search
@@ -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
Reference in new issue
Block a user