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
+827
-412
No files matched your search
@@ -3,6 +3,7 @@
|
|||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { act } from "react-dom/test-utils";
|
import { act } from "react-dom/test-utils";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { parseAssistantResponse } from "../lib/chat";
|
||||||
import type { DB } from "../lib/db";
|
import type { DB } from "../lib/db";
|
||||||
import ChatView from "./ChatView";
|
import ChatView from "./ChatView";
|
||||||
import type { Message } from "../lib/types";
|
import type { Message } from "../lib/types";
|
||||||
@@ -26,7 +27,7 @@ const defaultChatViewProps = {
|
|||||||
status: "error" as import("../lib/types").ServerStatus,
|
status: "error" as import("../lib/types").ServerStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("ChatView", () => {
|
describe.sequential("ChatView", () => {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let root: Root;
|
let root: Root;
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ describe("ChatView", () => {
|
|||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
container.remove();
|
container.remove();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
// @ts-expect-error React test environment flag
|
// @ts-expect-error React test environment flag
|
||||||
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
|
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
|
||||||
});
|
});
|
||||||
@@ -78,7 +80,7 @@ describe("ChatView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(textarea?.value).toBe("Keep this draft");
|
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();
|
expect(vi.mocked(db.run)).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -251,7 +253,7 @@ describe("ChatView", () => {
|
|||||||
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
|
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();
|
expect(sendButton).toBeTruthy();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -295,6 +297,115 @@ describe("ChatView", () => {
|
|||||||
expect(container.textContent).not.toContain("Late answer from first chat");
|
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 () => {
|
it("shows assistant reply after generation completes in an existing chat", async () => {
|
||||||
const rowsByChat = new Map<number, Message[]>([
|
const rowsByChat = new Map<number, Message[]>([
|
||||||
[1, [{ id: 11, chat_id: 1, role: "user", content: "Previous message", created_at: 1 }]],
|
[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 }));
|
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(); });
|
await act(async () => { sendButton?.click(); });
|
||||||
|
|
||||||
// During generation, user message should be visible
|
// During generation, user message should be visible
|
||||||
@@ -446,7 +557,7 @@ describe("ChatView", () => {
|
|||||||
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
|
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();
|
expect(sendButton).toBeTruthy();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -478,4 +589,102 @@ describe("ChatView", () => {
|
|||||||
|
|
||||||
expect(requestSignal.aborted).toBe(true);
|
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