A German user posted a screenshot on an Android forum. His interface was in German, his conversations with the assistant were in German, and the panel listing his past conversations was in French. Every title. A conversation held entirely in German about a trip planned for the next day was listed as "voyage pour demain", and he was politely asking whether his notifications had switched back to French on their own.
They had not. The titles had never been in German. XNeuronal ships in six languages, the assistant answers in the language you set, and one small side prompt had been quietly writing French into every account since the day it was born. This is the story of that bug, of the three siblings it turned out to have, and of the data repair that followed. It was fixed on 08/09/2026, in one backend commit. A Node backend in TypeScript, every block below copied from the repository and lightly trimmed.
Where the French came from
When a conversation ends, an archiver stores it, then asks the model for a four-to-six word title to show in a list. Here is the instruction it sent, the same for every account, as it stood before the fix:
const prompt: LLMMessage[] = [
{
role: 'system',
content:
"Résume en 4 à 6 mots ce dont on a discuté. Ton résumé sert de titre dans une liste. " +
"Pas de phrase complète, pas de ponctuation finale. Exemples : " +
"'recettes de pâtes carbonara', 'rdv dentiste & déménagement', 'voisin marc & courses'."
},
{ role: 'user', content: transcript }
];
A French instruction, French examples, and no word about which language the answer should be in. The transcript underneath was German. The model did the only sensible thing a French instruction with French examples allows: it answered in French.
That is the first lesson, and it is not about this model or that one. A model answers in the language of the instruction, not in the language of the content. If the system prompt is in French, the "obvious" language of the task is French, whatever the user message contains. When you write your prompts in your own language, the default is invisible to you, because it is also the language you test in. The bug shipped because the developer speaks French.
The fix, part one: the language becomes an argument
The backend already had a single registry of the languages it supports, used by the main conversation prompt, the transcription hints and the push texts:
export const SUPPORTED_LANGUAGES = ['fr', 'en', 'de', 'es', 'it', 'pt'] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
/** Native display names, used in the LLM output-language directive. */
export const LANGUAGE_NATIVE_NAMES: Record<SupportedLanguage, string> = {
fr: 'français', en: 'English', de: 'Deutsch', es: 'español', it: 'italiano', pt: 'português europeu'
};
The side prompts simply did not use it. They now live in one file, i18n/languagePrompts.ts, and every one of them takes a language:
export function conversationTitleInstruction(language: SupportedLanguage): string {
const native = LANGUAGE_NATIVE_NAMES[language];
return (
'Summarize in 4 to 6 words what the conversation was about. The summary is used as a title ' +
'in a list of past conversations. No full sentence, no final punctuation, no quotes. ' +
`Write the title in ${native}, whatever language the transcript is in. ` +
'Keep proper nouns (people, places) as they are written.'
);
}
Three choices in there, each deliberate.
The carrier language is English. Not because English is special, but because the instruction must no longer suggest an answer language by its own form; the explicit directive has to be the only signal, and English is the language the models follow most reliably for meta-instructions.
The target language is named in its native form ("Deutsch", not "German"), which is what the models latch onto with the fewest surprises.
And "whatever language the transcript is in" is there on purpose. A German owner who dictates a French sentence still reads the title in a German list. The language of a title belongs to the interface it appears in, not to the content it summarizes.
The archiver itself changed in two ways. It now reads the owner's language from the settings table, through an injected dependency so the tests never touch the database, and it exposes the title computation without the write, because a repair script was going to need it:
export interface ArchiverSettings {
getLanguage(ctx: AuthContext): Promise<SupportedLanguage>;
}
async titleFor(conv: ArchivableConversation, auth: AuthContext): Promise<string> {
const language = await this.ownerLanguage(auth);
return this.summarize(extractTurns(conv.history), language);
}
/** Defensive read : a settings hiccup must cost the language, never the title. */
private async ownerLanguage(auth: AuthContext): Promise<SupportedLanguage> {
try {
return asSupportedLanguage(await this.settings.getLanguage(auth));
} catch (err) {
console.warn('[archiver] language lookup failed, titling in French:', ...);
return 'fr';
}
}
The fallback deserves a sentence. The archiver runs after the conversation is over, and a title is a nicety. If the settings read fails, the worst acceptable outcome is a French title, the old defect for one conversation. Failing the archive would be a new defect introduced by the fix.
The fix, part two: a broken contract gets fixed on every path
Once you know the shape of the bug, "a French instruction sent to a model", you grep for it. The grep came back with three more hits, and this is the part I want to insist on, because it is where fixes usually stop short.
The first two were the attachment reader. When a user attaches a photo or a document, a vision prompt transcribes it and a summary prompt condenses it, both in French, with an explicit "Réponds en français" this time. Worse, the code building those prompts existed twice, copied and pasted: once in the upload-time extraction, once in the read_attachment tool the assistant calls mid-conversation. Fixing one copy would have given a German owner a German transcript on upload and a French one when asking for the same file read aloud.
Both copies were replaced by one factory:
export function buildReaderDeps(engines: ReaderEngines, language: SupportedLanguage): ReaderDeps {
const prompts = attachmentReaderPrompts(language);
return {
transcribe: (bytes, mime) =>
engines.stt.transcribe(bytes, `attachment.${mime.split('/')[1] ?? 'm4a'}`, {}).then((r) => r.text),
ocrImage: (bytes, mime) => engines.llm.visionDescribe(bytes, mime, prompts.ocr),
summarize: (text) =>
engines.llm.chat([
{ role: 'system', content: prompts.summarize },
{ role: 'user', content: text }
])
};
}
The upload path calls it with the language of the row's owner; the tool handler calls it with the language of the authenticated caller. Same engines, same prompts, one place to be wrong.
The third sibling was subtler because it was not a text output. The speech synthesis has a primary engine and a fallback engine, and the fallback accepts a free-text tone hint. That hint was a single French constant, exported from the fallback's module and imported by the conversation loop and two REST endpoints: "speak natural French, no foreign accent, warm and calm", sent for every language. A German owner who hit the fallback heard his German text read with a French accent. Nobody noticed, because the fallback rarely fires and the developer is French.
The constant is gone. In its place, one hint per supported language, keyed on the same registry:
const HINTS: Record<SupportedLanguage, string> = {
fr: 'Parle en français naturel, sans accent étranger. Ton chaleureux, posé, légèrement complice.',
en: 'Speak natural English, without a foreign accent. Warm, calm tone, slightly complicit.',
de: 'Sprich natürliches Deutsch, ohne fremden Akzent. Warmer, ruhiger Ton, leicht vertraut.',
// es, it, pt ...
};
export function ttsInstructionsFor(language: unknown): string {
return HINTS[asSupportedLanguage(language)];
}
Deleting the constant rather than renaming it is the point. A removed export cannot be re-imported by the next person who needs "the TTS instructions"; the compiler sends them to the function that asks for a language.
That last path forced a small structural change. The conversation loop already resolves the owner's language on every turn. The two REST endpoints did not: their guard decided whether the request was allowed and returned a boolean. It now returns the caller's identity, so the route can look up a language before synthesizing. Fixing a contract on every path sometimes means giving a path information it never needed before.
Testing a prompt without calling a model
None of the new tests call a model. They assert on the prompt that would be sent, captured by a fake:
test('enrich asks the title in the owner language read from the settings', async () => {
const { archiver, prompts } = makeArchiverWithLanguage('de');
await archiver.enrich('row-1', 'n1', conv([
{ role: 'user', text: 'Reise nach Lübeck morgen' }, { role: 'assistant', text: 'Notiert.' }
]), auth);
assert.equal(prompts.length, 1);
assert.match(prompts[0], /Deutsch/);
assert.doesNotMatch(prompts[0], /Résume|français/);
});
The negative assertion is the one that matters. /Deutsch/ proves the directive is there; /Résume|français/ proves the old French wording is not, which is exactly the regression a well-meaning refactor would reintroduce ("let me put the examples back, they helped").
A second test loops over the whole registry:
test('conversationTitleInstruction covers every supported language with its native name', () => {
for (const lang of SUPPORTED_LANGUAGES) {
assert.match(conversationTitleInstruction(lang), new RegExp(LANGUAGE_NATIVE_NAMES[lang]));
}
});
Together with Record<SupportedLanguage, string> on the hint tables, this means adding a seventh language to the registry fails at compile time until every table has an entry, and fails at test time until every prompt names it. The registry is the contract; the tests are what stop a path from silently opting out of it.
The third kind of test pins the fallback: the settings dependency throws, and the prompt must still be French, not an unhandled rejection. Defensive code that is not tested is a promise, not a behaviour.
Repairing what was already written
Fixing the code corrects every conversation archived from now on. The screenshot was about conversations archived last week, and those rows stay wrong forever unless something rewrites them. So the commit ships a script, scripts/retitleConversations.ts, and its design is the fourth lesson.
It is a dry run by default. --apply writes; nothing else does. --language de and --owner <id> narrow the scope. And it does not re-express the write in SQL: it calls the archiver's own enrich(), the tested implementation that updates the index record, its embedding and the conversation summary together, so repaired rows are indistinguishable from freshly archived ones. titleFor() exists so the dry run can show what would be written without writing it:
if (!apply) {
const title = await archiver.titleFor(snapshot, auth);
console.log(` ${row.id.slice(0, 8)} "${row.summary}" -> "${title}"`);
continue;
}
if (index) {
await archiver.enrich(row.id, index.id, snapshot, auth);
} else {
await conversationService.updateSummary(row.id, await archiver.titleFor(snapshot, auth));
}
Two decisions about scope. The script only selects owners whose language is not French, because French titles for French owners are correct, and re-titling them would change text people have already seen for no reason. Do not touch what is already right, even when touching it would be harmless. And it handles the row without an index record (older conversations predate that record), because a repair script that throws on the first odd row leaves the data in a state nobody planned for.
On idempotence, honestly: running it twice asks the model twice and may produce a slightly different title the second time. The write is stable in shape, not byte for byte. What makes it safe to re-run is the scope selection, not the write; a weaker guarantee than true idempotence, and worth knowing before you type --apply a second time.
The dry run in production listed about a dozen conversations across a handful of non-French accounts. The ones belonging to the user who reported the bug came back in German, and he could see it by simply reopening the list. The apply pass was run by a human with production credentials, after reading the dry run line by line, which is how this kind of script should always be run.
What I would do differently
The grep found the siblings, but a grep is a one-off. The durable version is a rule the code can enforce: no literal prompt string outside the i18n directory, checked by a test that scans the source tree for French wording next to a role: 'system'. We did not write that test. Until we do, the next French prompt will be caught by a German user again.
The language is now looked up separately in four places (archiver, upload extraction, tool handler, REST routes), each with its own defensive fallback. It should be a field of the request context, resolved once with the identity and passed down. Four correct lookups are still four places to forget one.
The examples in the old prompt were useful, and the new one has none, because examples in one language are exactly the signal we were trying to remove. Per-language examples would beat no examples, and they are one more table keyed on the registry.
And the one that costs nothing: test the product in a language you do not speak. Every one of these four paths behaved perfectly in French. The defect was only visible from a phone set to German, held by someone kind enough to send a screenshot.
Top comments (0)