fix(paliadin): used_tools NOT NULL violation + frontend response truncation

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.
This commit is contained in:
m
2026-05-08 14:00:03 +02:00
parent 5893c45e5e
commit be2150c17d
2 changed files with 21 additions and 4 deletions

View File

@@ -164,6 +164,12 @@ async function sendTurn(text: string): Promise<void> {
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;

View File

@@ -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
}