Files
llmgw/internal/web/chat_test.go
T
Ilker ManapandClaude Opus 5 0a98d01361 Say when a chat reply was cut off, and stop cutting it so early
Reported as the model not completing a request and stopping. It did
complete — it ran out of room. The chat page caps a turn at max_tokens and
then never looked at finish_reason, so a reply that hit the cap ended
mid-word and looked exactly like one that was finished.

The silence was the real defect. A reader has no way to tell "the model had
nothing more to say" from "the page stopped it", and the only clue is that
the last sentence ends abruptly.

512 was also too low. It was chosen when every model here was a 0.5B
answering in a paragraph; it is about a screenful of code, so a coding
model gets cut off inside the first function it writes. 2048 now, which is
still a bound — the reason for having one has not changed.

The notice is appended beside the reply, never into it: that element holds
model output placed with textContent, and building markup in the same node
is how the careful handling everywhere else gets undone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:03:07 +02:00

181 lines
6.2 KiB
Go

package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
func chatPage(t *testing.T, names ...string) string {
t.Helper()
models := make([]ChatModel, 0, len(names))
for _, n := range names {
models = append(models, ChatModel{Name: n})
}
return chatPageWith(t, models)
}
func chatPageWith(t *testing.T, models []ChatModel) string {
t.Helper()
h := Chat(store.User{Email: "a@example.com", ProjectID: "proj"}, models, "v1")
r := httptest.NewRequest(http.MethodGet, "http://x/chat", nil)
w := httptest.NewRecorder()
h(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d", w.Code)
}
return w.Body.String()
}
func TestMathIsRenderedWithoutTrustingTheModel(t *testing.T) {
// The ordering in the page is the safety argument, so it is worth asserting
// rather than remembering: the reply is put in place with textContent and
// only then walked for maths. KaTeX replaces the spans it recognises, so
// nothing the model wrote is ever parsed as HTML.
page := chatPage(t, "m")
if !strings.Contains(page, "/_static/katex/katex.min.css") ||
!strings.Contains(page, "/_static/katex/auto-render.min.js") {
t.Fatal("the page does not load KaTeX")
}
// Vendored, not fetched: this gateway makes no outbound requests and is
// expected to run where it could not.
for _, cdn := range []string{"cdn.jsdelivr", "unpkg.com", "cdnjs.", "//katex.org"} {
if strings.Contains(page, cdn) {
t.Errorf("the page loads KaTeX from %s instead of the binary", cdn)
}
}
if !strings.Contains(page, "trust: false") {
t.Error("trust is not pinned off; \\href in model output would become a link")
}
if !strings.Contains(page, "throwOnError: false") {
t.Error("a single malformed expression would take the whole reply with it")
}
// The reply still reaches the page as text first.
if !strings.Contains(page, "out.textContent = reply") {
t.Error("the reply is no longer written with textContent")
}
if strings.Contains(page, "innerHTML") {
t.Error("the chat page assigns innerHTML somewhere")
}
}
func TestMathIsRenderedOnceTheReplyIsComplete(t *testing.T) {
// Typesettingevery chunk would try to render half an expression on every token
// and flicker through the whole reply.
page := chatPage(t, "m")
body := page[strings.Index(page, "form.onsubmit"):]
renderAt := strings.Index(body, "renderMath(out)")
streamAt := strings.Index(body, "out.textContent = reply")
if renderAt < 0 || streamAt < 0 {
t.Fatal("the streaming loop or the render call is missing")
}
if renderAt < streamAt {
t.Error("maths is rendered before the text that carries it")
}
}
func TestDisplayDelimitersComeBeforeInlineOnes(t *testing.T) {
// $$…$$ has to be tried before $…$, or the opening $$ is read as an empty
// inline expression and everything after it is wrong.
page := chatPage(t, "m")
dd := strings.Index(page, `left: "$$"`)
d := strings.Index(page, `left: "$"`)
if dd < 0 || d < 0 {
t.Fatal("the delimiters are missing")
}
if dd > d {
t.Error(`$ is tried before $$`)
}
}
func TestTheChatPageOffersWhatItWasGiven(t *testing.T) {
page := chatPage(t, "alpha", "beta")
for _, m := range []string{"alpha", "beta"} {
if !strings.Contains(page, `value="`+m+`"`) {
t.Errorf("the picker does not offer %s", m)
}
}
empty := chatPageWith(t, nil)
if strings.Contains(empty, "<form id=\"composer\"") {
t.Error("a composer was rendered with no model to send to")
}
if !strings.Contains(empty, "No models are available") {
t.Error("the page does not explain why it is empty")
}
}
func TestTheSimulatedModelSaysSo(t *testing.T) {
// big-model returns Greek letters at a fixed rate. Offered next to a real
// model with nothing to tell them apart, it reads as a broken model rather
// than a load generator, which is how it was first reported.
page := chatPageWith(t, []ChatModel{
{Name: "qwen"},
{Name: "big-model", Simulated: true},
})
if !strings.Contains(page, `>big-model (simulated)<`) {
t.Error("the simulated model is offered without saying it is simulated")
}
if strings.Contains(page, `>qwen (simulated)<`) {
t.Error("a real model was labelled simulated")
}
if !strings.Contains(page, `value="big-model" data-simulated="1"`) ||
!strings.Contains(page, `value="qwen" data-simulated="0"`) {
t.Error("the script cannot tell the two apart")
}
if !strings.Contains(page, "placeholder text, not inference") {
t.Error("the page never explains what a simulated model returns")
}
}
func TestTheFirstOfferedModelAnswers(t *testing.T) {
// A browser selects the first option, so whatever leads the list is what
// someone gets when they type and press Send without looking at the picker.
page := chatPageWith(t, []ChatModel{
{Name: "qwen"},
{Name: "big-model", Simulated: true},
})
real, sim := strings.Index(page, `value="qwen"`), strings.Index(page, `value="big-model"`)
if real < 0 || sim < 0 {
t.Fatal("both models should be offered")
}
if real > sim {
t.Error("a simulated model is preselected; the default reply would be placeholder text")
}
}
func TestATruncatedReplySaysSo(t *testing.T) {
// A reply that hit the token cap looks exactly like one that finished: the
// stream ends and the text stops. Reported as the model not completing the
// request, which is a fair reading when nothing says otherwise.
page := chatPage(t, "m")
if !strings.Contains(page, "finish_reason") {
t.Error("the page never reads why the model stopped")
}
if !strings.Contains(page, `finish === "length"`) {
t.Error("the page does not distinguish running out of room from finishing")
}
if !strings.Contains(page, "Cut off at the length limit") {
t.Error("nothing tells the reader the answer was cut short")
}
}
func TestTheTruncationNoticeIsNotPutInsideTheReply(t *testing.T) {
// out holds model output placed with textContent. The notice is ours, so
// it goes beside that element — putting it inside would mean building
// markup in the same node the model writes into.
page := chatPage(t, "m")
if !strings.Contains(page, "out.after(note)") {
t.Error("the notice is not appended as a sibling of the reply")
}
if strings.Contains(page, "out.innerHTML") {
t.Error("the reply element is being written as HTML")
}
}