From 4e9f47b4a5afca9919a4ac3ae89286905aeceb9f Mon Sep 17 00:00:00 2001 From: agent_vscode Date: Tue, 7 Jul 2026 04:40:03 +0300 Subject: [PATCH] fix(ai-chat): strip NUL chars before persisting assistant rows A single NUL (U+0000) in model/tool output (e.g. a truncated multibyte read of a web page) is rejected by Postgres in BOTH the content (text) and toolCalls/metadata (jsonb) columns, so it failed EVERY write of the streaming assistant row ("invalid input syntax for type json") and silently dropped the turn's content from the DB while the live stream still showed it. - add stripNulChars: deep-strips NUL from all strings, returns the same reference when there is nothing to strip (no needless clone) - apply it at the flushAssistant choke point (covers content + toolCalls + metadata for the seed, per-step and terminal writes) - tests: deep-strip, same-reference, end-to-end via flushAssistant Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/core/ai-chat/ai-chat.service.spec.ts | 40 ++++++++++++++++ .../src/core/ai-chat/ai-chat.service.ts | 47 ++++++++++++++++++- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/apps/server/src/core/ai-chat/ai-chat.service.spec.ts b/apps/server/src/core/ai-chat/ai-chat.service.spec.ts index 31c985c4..44840acc 100644 --- a/apps/server/src/core/ai-chat/ai-chat.service.spec.ts +++ b/apps/server/src/core/ai-chat/ai-chat.service.spec.ts @@ -16,6 +16,7 @@ import { rowToUiMessage, prepareAgentStep, flushAssistant, + stripNulChars, chatStreamMetadata, accumulateStepUsage, isInterruptResume, @@ -448,6 +449,45 @@ describe('flushAssistant', () => { }); }); +/** + * stripNulChars: a NUL (U+0000) is rejected by Postgres in BOTH the `content` + * (text) and `toolCalls`/`metadata` (jsonb) columns, so it must be stripped from + * every persisted string. String.fromCharCode(0) avoids embedding a raw NUL byte + * in this source file. + */ +describe('stripNulChars', () => { + const NUL = String.fromCharCode(0); + + it('deep-strips NUL from strings in nested objects/arrays', () => { + const out = stripNulChars({ + content: `a${NUL}b`, + parts: [{ type: 'text', text: `x${NUL}${NUL}y` }], + nested: [`p${NUL}q`, 42, null], + }); + expect(out.content).toBe('ab'); + expect((out.parts[0] as { text: string }).text).toBe('xy'); + expect(out.nested[0]).toBe('pq'); + expect(out.nested[1]).toBe(42); + expect(out.nested[2]).toBeNull(); + expect(JSON.stringify(out).includes(NUL)).toBe(false); + }); + + it('returns the SAME reference when there is no NUL (no needless clone)', () => { + const input = { a: 'clean', b: [1, 2, { c: 'ok' }] }; + expect(stripNulChars(input)).toBe(input); + }); + + it('flushAssistant produces a NUL-free row even when the turn text carries one', () => { + const f = flushAssistant([], `partial${NUL}answer`, 'error', { + error: `bo${NUL}om`, + }); + expect(f.content).toBe('partialanswer'); + const serialized = + f.content + JSON.stringify(f.toolCalls) + JSON.stringify(f.metadata); + expect(serialized.includes(NUL)).toBe(false); + }); +}); + /** * chatStreamMetadata: attach metadata to the streamed assistant UI message per * part type — `chatId` on `start` (so the client adopts the real created chat id diff --git a/apps/server/src/core/ai-chat/ai-chat.service.ts b/apps/server/src/core/ai-chat/ai-chat.service.ts index 797d0746..f0793867 100644 --- a/apps/server/src/core/ai-chat/ai-chat.service.ts +++ b/apps/server/src/core/ai-chat/ai-chat.service.ts @@ -1876,6 +1876,45 @@ export async function applyFinalize( }); } +/** + * Deep-strip NUL characters (`\u0000`) from every string in a value, returning + * the SAME reference when nothing changed (so the no-NUL common case allocates + * nothing). Postgres rejects a NUL in BOTH `text` and `jsonb` columns ("invalid + * input syntax for type json" / "unsupported Unicode escape sequence"), so a + * stray NUL in model output or a tool result — e.g. a truncated multibyte read + * of a web page — otherwise fails EVERY persist of the assistant row, silently + * dropping that turn's content from the DB while the live stream still shows it. + * Applied at the flushAssistant choke point so content + toolCalls + metadata are + * all covered. Exported for the unit test. + */ +export function stripNulChars(value: T): T { + if (typeof value === 'string') { + return (value.includes('\u0000') + ? value.replace(/\u0000/g, '') + : value) as T; + } + if (Array.isArray(value)) { + let changed = false; + const out = value.map((v) => { + const s = stripNulChars(v); + if (s !== v) changed = true; + return s; + }); + return (changed ? out : value) as T; + } + if (value && typeof value === 'object') { + let changed = false; + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + const s = stripNulChars(v); + if (s !== v) changed = true; + out[k] = s; + } + return (changed ? out : value) as T; + } + return value; +} + /** * PURE assistant-row builder (#183 step-granular durability). Given the turn's * accumulated steps + the in-progress (not-yet-finished) text + the lifecycle @@ -1945,12 +1984,16 @@ export function flushAssistant( }; } - return { + // Strip NUL chars from the whole row before persisting: Postgres rejects a NUL + // in both the `content` (text) and `toolCalls`/`metadata` (jsonb) columns, and a + // single stray NUL in model/tool output would otherwise fail EVERY write of this + // row and silently drop the turn's content from the DB (see stripNulChars). + return stripNulChars({ content: stepsText + trailing, toolCalls: serializeSteps(finished), metadata, status, - }; + }); } /**