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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<T>(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<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user