From be2150c17de0fd98431d264fe95e809a20cad077 Mon Sep 17 00:00:00 2001 From: m Date: Fri, 8 May 2026 14:00:03 +0200 Subject: [PATCH] fix(paliadin): used_tools NOT NULL violation + frontend response truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs surfaced in m's dogfood of t-paliad-155 (2026-05-08 13:55). ## A. used_tools NOT NULL constraint violation on casual turns paliad.paliadin_turns.used_tools is text[] NOT NULL DEFAULT '{}'. parseTrailer leaves trailerMeta.UsedTools as nil when Claude omits the trailer ("Heyhey!") or sends an empty list. completeTurn passed pq.StringArray(nil) which the pq driver writes as NULL — UPDATE failed with constraint 23502 on every casual chat turn, leaving the row half-finalized. Fix: coerce UsedTools to a non-nil empty pq.StringArray before the UPDATE, mirroring the existing rowsSeen pattern in the same function. ## B. Frontend rendered "## Proje" instead of the full 1408-byte response m saw the first 8 characters of his Markdown response in the chat bubble, plus the full meta row underneath. The DB row had the complete cleanBody in 'response'. Truncation lived entirely in the browser. Root cause: finishBubble read textNode.textContent at the moment of the 'end' event — but typewriter() animates the text 8 chars at a time, so textContent was "## Proje" (one tick into 1408 bytes) when finishBubble fired. renderResponseHTML(raw) baked in the partial state, then the typewriter's next tick saw streaming='false' and ran 'node.textContent = text' which overwrote the rendered HTML with the raw string — except in this case the second tick never ran in time, leaving the partial render. Fix: 1. Cache the full SSE-delivered text on placeholder.dataset.fullText at content-event time. finishBubble prefers that over textContent. 2. Typewriter's abort branch no longer overwrites the node — finishBubble already owns the final rendered HTML, so a delayed tick should just return rather than blow away the rendered Markdown. Both fixes verified locally: go build clean, bun build clean. Refs t-paliad-155, m/paliad#12. --- frontend/src/client/paliadin.ts | 15 ++++++++++++--- internal/services/paliadin.go | 10 +++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/frontend/src/client/paliadin.ts b/frontend/src/client/paliadin.ts index d3c174e..a7abd73 100644 --- a/frontend/src/client/paliadin.ts +++ b/frontend/src/client/paliadin.ts @@ -164,6 +164,12 @@ async function sendTurn(text: string): Promise { es.addEventListener("content", (ev) => { const data = JSON.parse((ev as MessageEvent).data); const text = String(data.text || ""); + // Cache the full text on the bubble so finishBubble can render the + // complete response even when the typewriter is mid-flight when end + // arrives. textContent reflects only what's been typed so far and + // would otherwise truncate the rendered Markdown (m, 2026-05-08 — + // saw "## Proje" instead of the full 1408-byte body). + placeholder.dataset.fullText = text; typewriter(placeholder, text); }); @@ -282,8 +288,9 @@ function typewriter(bubble: HTMLElement, text: string): void { const speed = 6; const tick = () => { if (bubble.dataset.streaming !== "true") { - // Aborted — flush remaining text instantly. - node.textContent = text; + // Streaming finished — finishBubble has already rendered the full + // Markdown via dataset.fullText. Return without writing so we + // don't replace the rendered HTML with raw text on a delayed tick. return; } if (i >= text.length) return; @@ -307,7 +314,9 @@ function getBubbleText(bubble: HTMLElement): string { // "ran search_my_deadlines (3 results)". function finishBubble(bubble: HTMLElement, data: any): void { const textNode = bubble.querySelector(".paliadin-bubble-text")! as HTMLElement; - const raw = textNode.textContent || ""; + // Prefer the full text cached on the bubble at content-event time; + // textContent may still reflect the typewriter's partial state. + const raw = bubble.dataset.fullText ?? textNode.textContent ?? ""; textNode.innerHTML = renderResponseHTML(raw); const metaEl = bubble.querySelector(".paliadin-bubble-meta") as HTMLElement | null; diff --git a/internal/services/paliadin.go b/internal/services/paliadin.go index 6669481..36f0d2f 100644 --- a/internal/services/paliadin.go +++ b/internal/services/paliadin.go @@ -750,6 +750,14 @@ func (s *paliadinDB) insertTurnRow(ctx context.Context, t *PaliadinTurn) error { func (s *paliadinDB) completeTurn(ctx context.Context, turnID uuid.UUID, finishedAt time.Time, durationMS int, response string, tokens int, meta trailerMeta, chipCount int) error { + // used_tools and rows_seen are NOT NULL in the schema (default '{}'). + // parseTrailer leaves them nil when Claude omits the trailer or the + // turn has no tool calls (casual chat). pq treats nil slices as NULL, + // so we must coerce to a non-nil empty array on every path. + usedTools := make(pq.StringArray, 0, len(meta.UsedTools)) + for _, t := range meta.UsedTools { + usedTools = append(usedTools, t) + } rowsSeen := make(pq.Int64Array, 0, len(meta.RowsSeen)) for _, n := range meta.RowsSeen { rowsSeen = append(rowsSeen, int64(n)) @@ -768,7 +776,7 @@ func (s *paliadinDB) completeTurn(ctx context.Context, turnID uuid.UUID, ` _, err := s.db.ExecContext(ctx, q, turnID, finishedAt, durationMS, response, tokens, - pq.StringArray(meta.UsedTools), rowsSeen, chipCount, + usedTools, rowsSeen, chipCount, optionalString(meta.ClassifierTag)) return err }