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, - }; + }); } /**