feat(wizard): separate UI, conversation and content languages - #108
Conversation
…intent # Conflicts: # app/_launch_runtime.py
PR Review — Loreframe StudioRisk: medium Automated review from Findings
Changed files
CONTRIBUTING checklist
Posted by the repo PR review workflow. Re-runs on each push to the PR. |
Code health
Markdown, JSON catalogs and tests are out of this table. Only Most complex functions
Trend vs baseline
Warnings
Ratchet passed. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Language-only project updates are dropped
- update_story and update_series_episode now treat language_intent as a real patch, attach it before validate, and accept language-only updates of the open project.
- ✅ Fixed: Story generation does not persist intent
- generate_story_section now writes the merged languageIntent to the library, and the writer overview schema plus apply path can persist spokenLanguage and verbatimSegments.
Or push these changes by commenting:
@cursor push 555f85ef36
Preview (555f85ef36)
diff --git a/app/_launch_runtime.py b/app/_launch_runtime.py
--- a/app/_launch_runtime.py
+++ b/app/_launch_runtime.py
@@ -26917,6 +26917,37 @@
beat_maximum = 8 if project_type == "quick_video" else 10 if project_type == "music_video" else 12 if project_type == "trailer" else 14
music_minimum = 1 if project_type == "music_video" else 4
music_maximum = 1 if project_type == "music_video" else 16
+ language_intent = {
+ "type": "object",
+ "properties": {
+ "conversationLanguage": string,
+ "contentLanguage": string,
+ "spokenLanguage": string,
+ "technicalPromptLanguage": {"type": "string", "enum": ["auto", "en"]},
+ "verbatimSegments": {
+ "type": "array",
+ "maxItems": 40,
+ "items": {
+ "type": "object",
+ "properties": {
+ "kind": {"type": "string", "enum": [
+ "dialogue", "lyrics", "visible_text", "subtitle", "name",
+ ]},
+ "text": string,
+ "language": string,
+ "speaker": string,
+ },
+ "required": ["kind", "text"],
+ "additionalProperties": False,
+ },
+ },
+ },
+ "required": [
+ "conversationLanguage", "contentLanguage", "spokenLanguage",
+ "technicalPromptLanguage", "verbatimSegments",
+ ],
+ "additionalProperties": False,
+ }
creative_brief = {
"type": "object",
"properties": {
@@ -26939,7 +26970,9 @@
"type": "object",
"properties": {
"title": string, "creativeBrief": creative_brief,
- "language": string, "genre": string, "tone": string,
+ "language": string, "spokenLanguage": string,
+ "languageIntent": language_intent,
+ "genre": string, "tone": string,
"audience": string, "visualStyle": string,
"characterVisualStyle": string,
"enforceVisualStyle": {"type": "boolean"},
diff --git a/tests/test_story_lab_music_plan.py b/tests/test_story_lab_music_plan.py
--- a/tests/test_story_lab_music_plan.py
+++ b/tests/test_story_lab_music_plan.py
@@ -184,6 +184,13 @@
self.assertIn("[Chorus]", repaired["lyrics"])
self.assertIsNone(_story_stage_problem(normalized, "music", project))
+ def test_overview_schema_can_carry_language_intent(self):
+ overview = _story_lab_schema("overview")["properties"]["overview"]
+ self.assertIn("languageIntent", overview["properties"])
+ self.assertIn("spokenLanguage", overview["properties"])
+ self.assertNotIn("languageIntent", overview["required"])
+ self.assertIn("verbatimSegments", overview["properties"]["languageIntent"]["properties"])
+
def test_quick_video_structure_is_compact(self):
schema = _story_lab_schema("beats", "quick_video")
beats = schema["properties"]["beats"]
diff --git a/ui/src/features/agent/capabilityRegistry.ts b/ui/src/features/agent/capabilityRegistry.ts
--- a/ui/src/features/agent/capabilityRegistry.ts
+++ b/ui/src/features/agent/capabilityRegistry.ts
@@ -47,6 +47,7 @@
import {
LANGUAGE_INTENT_SCHEMA,
compileProviderPrompt,
+ hasLanguageIntent,
normalizeConversationLanguageTag,
normalizeLanguageIntent,
type LanguageIntent,
@@ -559,10 +560,10 @@
resolve(raw) {
const fields = storyFields(raw)
const action: AgentUpdateStoryAction = { type: 'update_story', targetStoryTitle: text(raw.target_story_title, 300), ...fields }
- const hasPatch = action.title || action.creativeBrief || action.premise || action.logline || action.synopsis || action.theme || action.ending || action.genre || action.tone || action.visualStyle || action.worldSummary || action.language || action.characters.length || action.locations.length || action.outlineBeats.length || action.durationSeconds !== undefined
+ const hasPatch = action.title || action.creativeBrief || action.premise || action.logline || action.synopsis || action.theme || action.ending || action.genre || action.tone || action.visualStyle || action.worldSummary || action.language || action.characters.length || action.locations.length || action.outlineBeats.length || action.durationSeconds !== undefined || hasLanguageIntent(raw.language_intent)
return hasPatch ? action : null
},
- validate(action) { return action.targetStoryTitle || action.title || action.premise ? [] : ['a target story or a patch is required'] }, async prepare(action) { return action },
+ validate(action) { return action.targetStoryTitle || action.title || action.premise || hasLanguageIntent(action.languageIntent) ? [] : ['a target story or a patch is required'] }, async prepare(action) { return action },
async execute(action, context) { return context.adapters.storyLab.update(action) }, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome },
report: { targetKind: 'story', successState: 'completed' }, summarize(_action, outcome) { return outcome.message },
presentation: { destination: 'story_lab', anchors: ['overview', 'characters', 'world', 'structure'], replay: 'atomic' },
@@ -704,9 +705,9 @@
resolve(raw) {
const fields = seriesEpisodeFields(raw)
const action: AgentUpdateSeriesEpisodeAction = { type: 'update_series_episode', seriesTitle: fields.seriesTitle, targetEpisodeTitle: text(raw.target_episode_title, 300), episodeTitle: fields.episodeTitle, episodePremise: fields.episodePremise, episodeLogline: fields.episodeLogline, outlineBeats: fields.outlineBeats, targetDurationSeconds: fields.targetDurationSeconds }
- return action.episodeTitle || action.episodePremise || action.episodeLogline || action.outlineBeats.length || action.targetDurationSeconds !== undefined ? action : null
+ return action.episodeTitle || action.episodePremise || action.episodeLogline || action.outlineBeats.length || action.targetDurationSeconds !== undefined || hasLanguageIntent(raw.language_intent) ? action : null
},
- validate(action) { return action.episodeTitle || action.episodePremise || action.episodeLogline || action.outlineBeats.length || action.targetDurationSeconds !== undefined ? [] : ['an episode patch is required'] }, async prepare(action) { return action },
+ validate(action) { return action.episodeTitle || action.episodePremise || action.episodeLogline || action.outlineBeats.length || action.targetDurationSeconds !== undefined || hasLanguageIntent(action.languageIntent) ? [] : ['an episode patch is required'] }, async prepare(action) { return action },
async execute(action, context) { return context.adapters.seriesLab.updateEpisode(action) }, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome },
report: { targetKind: 'series_episode', successState: 'completed' }, summarize(_action, outcome) { return outcome.message },
presentation: { destination: 'series_lab', anchors: ['episode'], replay: 'atomic' },
@@ -1106,11 +1107,13 @@
const definition = definitions.get(name)
if (!definition) return undefined
const action = definition.resolve(raw)
- if (!action || definition.validate(action).length) return null
- if (!LANGUAGE_AWARE_CAPABILITIES.has(action.type)) return action
- const rawIntent = raw.language_intent
- if (!rawIntent || typeof rawIntent !== 'object' || Array.isArray(rawIntent)) return action
- return { ...action, languageIntent: normalizeLanguageIntent(rawIntent) } as AgentAction
+ if (!action) return null
+ const prepared = LANGUAGE_AWARE_CAPABILITIES.has(action.type)
+ && raw.language_intent && typeof raw.language_intent === 'object' && !Array.isArray(raw.language_intent)
+ ? { ...action, languageIntent: normalizeLanguageIntent(raw.language_intent) } as AgentAction
+ : action
+ if (definition.validate(prepared).length) return null
+ return prepared
}
export async function executeRegisteredCapability(
diff --git a/ui/src/features/stories/actions.ts b/ui/src/features/stories/actions.ts
--- a/ui/src/features/stories/actions.ts
+++ b/ui/src/features/stories/actions.ts
@@ -8,7 +8,7 @@
outlineBeats,
} from '../../lib/labHelpers'
import { useStore } from '../../stores/useStore'
-import { compileProviderPrompt, mergeLanguageIntent } from '../../lib/languageIntent'
+import { compileProviderPrompt, mergeLanguageIntent, normalizeLanguageIntent } from '../../lib/languageIntent'
import { applyMusicVideoDirectVideoDefaults, resolveMusicVideoVisualStyle } from './musicVideoLook'
import type {
ApplyStoryProposalCommand,
@@ -710,22 +710,25 @@
? Object.values(current.projects).find(item => normalizeName(item.title) === normalizeName(action.targetStoryTitle))
: current.project
if (!storedProject) throw new Error(`No existe la historia “${action.targetStoryTitle}” en este workspace.`)
- const project = action.languageIntent ? {
- ...storedProject,
- languageIntent: mergeLanguageIntent(storedProject.languageIntent, action.languageIntent),
- language: action.languageIntent.contentLanguage || storedProject.language,
- spokenLanguage: action.languageIntent.spokenLanguage || storedProject.spokenLanguage,
- } : storedProject
- if (current.activeProjectOperations[project.id]) {
- throw new Error(`La historia “${project.title}” ya tiene una operación activa.`)
+ if (current.activeProjectOperations[storedProject.id]) {
+ throw new Error(`La historia “${storedProject.title}” ya tiene una operación activa.`)
}
+ const project = action.languageIntent
+ ? await saveActiveStoryProjectMutation(workspace, current, storedProject.id, source => ({
+ ...source,
+ languageIntent: mergeLanguageIntent(source.languageIntent, action.languageIntent),
+ language: action.languageIntent.contentLanguage || source.language,
+ spokenLanguage: action.languageIntent.spokenLanguage || source.spokenLanguage,
+ updatedAt: new Date().toISOString(),
+ }))
+ : storedProject
const premise = project.premise.trim()
|| project.creativeBrief.generalIdea.trim()
|| project.logline.trim()
|| project.synopsis.trim()
if (!premise) throw new Error(`“${project.title}” necesita una premisa o briefing antes de invocar al escritor.`)
- useStoryStore.setState({ project, dirty: false })
+ if (!action.languageIntent) useStoryStore.setState({ project, dirty: false })
const visibleSection = action.scope === 'all' ? 'overview' : action.scope
const resultKey = `maestro-story-plan-result:${workspace}:${project.id}`
const jobKey = `maestro-story-plan-job:${workspace}:${project.id}`
@@ -840,6 +843,14 @@
}
})
}
+ if (overview.languageIntent && typeof overview.languageIntent === 'object' && !Array.isArray(overview.languageIntent)) {
+ candidate.languageIntent = mergeLanguageIntent(
+ candidate.languageIntent,
+ normalizeLanguageIntent(overview.languageIntent),
+ )
+ candidate.language = candidate.languageIntent.contentLanguage || candidate.language
+ candidate.spokenLanguage = candidate.languageIntent.spokenLanguage || candidate.spokenLanguage
+ }
}
const generatedWorld = result.world && typeof result.world === 'object' && !Array.isArray(result.world)
diff --git a/ui/tests/languageIntent.test.ts b/ui/tests/languageIntent.test.ts
--- a/ui/tests/languageIntent.test.ts
+++ b/ui/tests/languageIntent.test.ts
@@ -171,6 +171,29 @@
assert.equal(series?.languageIntent.technicalPromptLanguage, 'en')
})
+test('language-only Story and Series updates resolve instead of being dropped', () => {
+ const story = parseRegisteredCapability('update_story', {
+ type: 'update_story',
+ language_intent: {
+ spoken_language: 'Català',
+ verbatim_segments: [{ kind: 'dialogue', text: 'hola', language: 'ca' }],
+ },
+ })
+ assert.equal(story?.type, 'update_story')
+ assert.equal(story && 'languageIntent' in story && story.languageIntent?.spokenLanguage, 'Català')
+ assert.equal(story && 'languageIntent' in story && story.languageIntent?.verbatimSegments[0].text, 'hola')
+
+ const episode = parseRegisteredCapability('update_series_episode', {
+ type: 'update_series_episode',
+ language_intent: { spoken_language: 'Català' },
+ })
+ assert.equal(episode?.type, 'update_series_episode')
+ assert.equal(episode && 'languageIntent' in episode && episode.languageIntent?.spokenLanguage, 'Català')
+
+ assert.equal(parseRegisteredCapability('update_story', { type: 'update_story' }), null)
+ assert.equal(parseRegisteredCapability('update_series_episode', { type: 'update_series_episode' }), null)
+})
+
test('changing only protected Story literals is a real persisted overview change', () => {
const before = createStoryProject()
const after = normalizeStoryProject({You can send follow-ups to the cloud agent here.
|
@cursor review |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Language persist runs before validation
- Moved Story and Series Lab validation ahead of the language persist so a rejected turn no longer writes languageIntent or unapproves Overview/canon.
Or push these changes by commenting:
@cursor push ea6147c864
Preview (ea6147c864)
diff --git a/ui/src/features/series/actions.ts b/ui/src/features/series/actions.ts
--- a/ui/src/features/series/actions.ts
+++ b/ui/src/features/series/actions.ts
@@ -292,6 +292,23 @@
if (!series) throw new Error(action.seriesTitle
? `No existe la serie “${action.seriesTitle}” en este workspace.`
: 'No hay una serie activa que planificar.')
+ const episodeMatches = action.targetEpisodeTitle
+ ? Object.values(series.episodesById).filter(item => normalizeName(item.title) === normalizeName(action.targetEpisodeTitle))
+ : []
+ if (episodeMatches.length > 1) throw new Error(`Hay varios episodios titulados “${action.targetEpisodeTitle}”; el destino no es inequívoco.`)
+ const activeEpisodeId = useSeriesStore.getState().activeSeriesId === series.id
+ ? useSeriesStore.getState().activeEpisodeId : ''
+ const episodes = Object.values(series.episodesById)
+ const episode = episodeMatches[0]
+ || (!action.targetEpisodeTitle && activeEpisodeId ? series.episodesById[activeEpisodeId] : null)
+ || (!action.targetEpisodeTitle && episodes.length === 1 ? episodes[0] : null)
+ if (!episode) throw new Error(action.targetEpisodeTitle
+ ? `No existe el episodio “${action.targetEpisodeTitle}” en “${series.title}”.`
+ : `“${series.title}” necesita un episodio activo o único.`)
+ if (!episode.premise.trim()) throw new Error(`“${episode.title}” necesita una premisa antes de planificarse.`)
+ if (action.scope === 'shots' && !episode.script.length) {
+ throw new Error('Regenerar shots requiere un guion existente; genera script o complete primero.')
+ }
if (action.languageIntent) {
const languageIntent = mergeLanguageIntent(series.languageIntent, action.languageIntent, {
contentLanguage: series.language,
@@ -315,23 +332,6 @@
await useSeriesStore.getState().loadWorkspace(workspace)
}
}
- const episodeMatches = action.targetEpisodeTitle
- ? Object.values(series.episodesById).filter(item => normalizeName(item.title) === normalizeName(action.targetEpisodeTitle))
- : []
- if (episodeMatches.length > 1) throw new Error(`Hay varios episodios titulados “${action.targetEpisodeTitle}”; el destino no es inequívoco.`)
- const activeEpisodeId = useSeriesStore.getState().activeSeriesId === series.id
- ? useSeriesStore.getState().activeEpisodeId : ''
- const episodes = Object.values(series.episodesById)
- const episode = episodeMatches[0]
- || (!action.targetEpisodeTitle && activeEpisodeId ? series.episodesById[activeEpisodeId] : null)
- || (!action.targetEpisodeTitle && episodes.length === 1 ? episodes[0] : null)
- if (!episode) throw new Error(action.targetEpisodeTitle
- ? `No existe el episodio “${action.targetEpisodeTitle}” en “${series.title}”.`
- : `“${series.title}” necesita un episodio activo o único.`)
- if (!episode.premise.trim()) throw new Error(`“${episode.title}” necesita una premisa antes de planificarse.`)
- if (action.scope === 'shots' && !episode.script.length) {
- throw new Error('Regenerar shots requiere un guion existente; genera script o complete primero.')
- }
await useSeriesStore.getState().openSeries(series.id)
useSeriesStore.getState().openEpisode(episode.id)
diff --git a/ui/src/features/stories/actions.ts b/ui/src/features/stories/actions.ts
--- a/ui/src/features/stories/actions.ts
+++ b/ui/src/features/stories/actions.ts
@@ -713,6 +713,11 @@
if (current.activeProjectOperations[storedProject.id]) {
throw new Error(`La historia “${storedProject.title}” ya tiene una operación activa.`)
}
+ const premise = storedProject.premise.trim()
+ || storedProject.creativeBrief.generalIdea.trim()
+ || storedProject.logline.trim()
+ || storedProject.synopsis.trim()
+ if (!premise) throw new Error(`“${storedProject.title}” necesita una premisa o briefing antes de invocar al escritor.`)
let project = storedProject
if (action.languageIntent) {
const intended = mergeLanguageIntent(storedProject.languageIntent, action.languageIntent, {
@@ -749,11 +754,6 @@
})
}
}
- const premise = project.premise.trim()
- || project.creativeBrief.generalIdea.trim()
- || project.logline.trim()
- || project.synopsis.trim()
- if (!premise) throw new Error(`“${project.title}” necesita una premisa o briefing antes de invocar al escritor.`)
useStoryStore.setState({ project, dirty: false })
const visibleSection = action.scope === 'all' ? 'overview' : action.scopeYou can send follow-ups to the cloud agent here.
|
He corregido el fallo real del ratchet sin relajar controles: la aplicación y persistencia del contrato de idioma de Story Lab se extrajo a |
|
@cursor review |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for all 3 issues found in the latest run.
- ✅ Fixed: Series language saved before validation
- updateSeriesEpisode now resolves a unique target episode first and only then persists languageIntent when the merged contract actually differs.
- ✅ Fixed: Episode create resets approved canon
- Creating a later episode no longer treats languageIntent as needsSetup; unchanged language is inherited and only real language changes are saved without drafting canon.
- ✅ Fixed: Legacy language desyncs language intent
- A legacy language patch now rebuilds languageIntent from the updated language and spokenLanguage so the persisted contract stays aligned.
Or push these changes by commenting:
@cursor push 15e87d3494
Preview (15e87d3494)
diff --git a/ui/src/features/series/actions.ts b/ui/src/features/series/actions.ts
--- a/ui/src/features/series/actions.ts
+++ b/ui/src/features/series/actions.ts
@@ -117,13 +117,17 @@
spokenLanguage: action.language || series.spokenLanguage,
technicalPromptLanguage: 'en',
})
+ const language = languageIntent.contentLanguage || action.language || series.language
+ const spokenLanguage = languageIntent.spokenLanguage || action.language || series.spokenLanguage
+ const languageChanged = JSON.stringify(languageIntent) !== JSON.stringify(series.languageIntent)
+ || language !== series.language
+ || spokenLanguage !== series.spokenLanguage
const needsSetup = createdSeries
|| !series.premise.trim()
|| !series.visualStyle.trim()
|| !series.canon.worldSummary.trim()
|| !series.characters.length
|| !series.locations.length
- || Boolean(action.languageIntent)
if (needsSetup) {
const patched = {
...series,
@@ -135,8 +139,8 @@
visualStyle: series.visualStyle || action.visualStyle || 'Continuidad televisiva cinematográfica, composición clara y personajes consistentes.',
characterVisualStyle: series.characterVisualStyle || action.visualStyle || 'Identidades y vestuario consistentes entre episodios.',
cameraLanguage: series.cameraLanguage || 'Planos de situación claros, planos medios para diálogo y primeros planos para reacciones.',
- language: languageIntent.contentLanguage || action.language || series.language,
- spokenLanguage: languageIntent.spokenLanguage || action.language || series.spokenLanguage,
+ language,
+ spokenLanguage,
languageIntent,
sourceMode: action.knownUniverse ? 'known_universe_experimental' as const : series.sourceMode,
masterUniversePrompt: series.masterUniversePrompt || (action.knownUniverse
@@ -162,6 +166,14 @@
updatedAt: new Date().toISOString(),
}
series = await api.saveSeriesProject(workspace, patched, series.revision)
+ } else if (languageChanged) {
+ series = await api.saveSeriesProject(workspace, {
+ ...series,
+ language,
+ spokenLanguage,
+ languageIntent,
+ updatedAt: new Date().toISOString(),
+ }, series.revision)
}
let approvedCanon = false
if (series.canon.approval !== 'approved') {
@@ -217,18 +229,6 @@
if (!series) throw new Error(action.seriesTitle
? `No existe la serie “${action.seriesTitle}” en este workspace.`
: 'No hay una serie activa que modificar.')
- if (action.languageIntent) {
- const languageIntent = mergeLanguageIntent(series.languageIntent, action.languageIntent)
- series = await api.saveSeriesProject(workspace, {
- ...series,
- language: languageIntent.contentLanguage || series.language,
- spokenLanguage: languageIntent.spokenLanguage || series.spokenLanguage,
- languageIntent,
- updatedAt: new Date().toISOString(),
- }, series.revision)
- useSeriesStore.setState({ hydrated: false })
- await useSeriesStore.getState().loadWorkspace(workspace)
- }
const episodeMatches = action.targetEpisodeTitle
? Object.values(series.episodesById).filter(item => normalizeName(item.title) === normalizeName(action.targetEpisodeTitle))
@@ -245,6 +245,29 @@
if (!episode) throw new Error(action.targetEpisodeTitle
? `No existe el episodio “${action.targetEpisodeTitle}” en “${series.title}”.`
: `“${series.title}” necesita un episodio activo o un único episodio para poder inferir el destino.`)
+ if (action.languageIntent) {
+ const languageIntent = mergeLanguageIntent(series.languageIntent, action.languageIntent, {
+ contentLanguage: series.language,
+ spokenLanguage: series.spokenLanguage,
+ })
+ const language = languageIntent.contentLanguage || series.language
+ const spokenLanguage = languageIntent.spokenLanguage || series.spokenLanguage
+ if (
+ JSON.stringify(languageIntent) !== JSON.stringify(series.languageIntent)
+ || language !== series.language
+ || spokenLanguage !== series.spokenLanguage
+ ) {
+ series = await api.saveSeriesProject(workspace, {
+ ...series,
+ language,
+ spokenLanguage,
+ languageIntent,
+ updatedAt: new Date().toISOString(),
+ }, series.revision)
+ useSeriesStore.setState({ hydrated: false })
+ await useSeriesStore.getState().loadWorkspace(workspace)
+ }
+ }
await useSeriesStore.getState().openSeries(series.id)
useSeriesStore.getState().openEpisode(episode.id)
diff --git a/ui/src/features/stories/actions.ts b/ui/src/features/stories/actions.ts
--- a/ui/src/features/stories/actions.ts
+++ b/ui/src/features/stories/actions.ts
@@ -562,7 +562,7 @@
candidate.language = action.language
if (!action.languageIntent?.spokenLanguage) candidate.spokenLanguage = action.language
}
- if (action.languageIntent) {
+ if (action.languageIntent || action.language) {
Object.assign(candidate, applyStoryLanguageIntent(candidate, action.languageIntent))
}
diff --git a/ui/src/features/stories/languageIntent.ts b/ui/src/features/stories/languageIntent.ts
--- a/ui/src/features/stories/languageIntent.ts
+++ b/ui/src/features/stories/languageIntent.ts
@@ -6,8 +6,25 @@
update: LanguageIntent | undefined,
fallback: Partial<LanguageIntent> = {},
): StoryProject {
- if (!update && !Object.keys(fallback).length) return project
- const languageIntent = mergeLanguageIntent(project.languageIntent, update, {
+ const languageChanged = project.language !== project.languageIntent.contentLanguage
+ || project.spokenLanguage !== project.languageIntent.spokenLanguage
+ const syncedUpdate = update
+ ? {
+ ...update,
+ contentLanguage: update.contentLanguage || project.language,
+ spokenLanguage: update.spokenLanguage || project.spokenLanguage,
+ }
+ : languageChanged
+ ? {
+ conversationLanguage: '',
+ contentLanguage: project.language,
+ spokenLanguage: project.spokenLanguage,
+ technicalPromptLanguage: project.languageIntent.technicalPromptLanguage,
+ verbatimSegments: [],
+ }
+ : undefined
+ if (!syncedUpdate && !Object.keys(fallback).length) return project
+ const languageIntent = mergeLanguageIntent(project.languageIntent, syncedUpdate, {
contentLanguage: project.language,
spokenLanguage: project.spokenLanguage,
...fallback,You can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Legacy language overwrites series speech
- resolveSeriesLanguageIntent now keeps an existing spokenLanguage unless the update supplies a new spoken value, so restating the legacy content language no longer collapses speech or drops canon to draft.
Or push these changes by commenting:
@cursor push 5b4505ce88
Preview (5b4505ce88)
diff --git a/ui/src/features/series/languageIntent.ts b/ui/src/features/series/languageIntent.ts
--- a/ui/src/features/series/languageIntent.ts
+++ b/ui/src/features/series/languageIntent.ts
@@ -15,7 +15,7 @@
return {
...merged,
contentLanguage: update?.contentLanguage || legacyLanguage,
- spokenLanguage: update?.spokenLanguage || legacyLanguage,
+ spokenLanguage: update?.spokenLanguage || merged.spokenLanguage || legacyLanguage,
}
}
diff --git a/ui/tests/languageIntent.test.ts b/ui/tests/languageIntent.test.ts
--- a/ui/tests/languageIntent.test.ts
+++ b/ui/tests/languageIntent.test.ts
@@ -288,8 +288,16 @@
const legacySelection = resolveSeriesLanguageIntent(series!, 'Français', undefined)
assert.equal(legacySelection.contentLanguage, 'Français')
- assert.equal(legacySelection.spokenLanguage, 'Français')
+ assert.equal(legacySelection.spokenLanguage, 'Español')
assert.equal(seriesLanguageIntentAffectsCanon(series!, legacySelection), true)
+
+ const splitSpeech = normalizeSeriesProject({
+ id: 'series-speech', title: 'Night Shift', language: 'Español', spokenLanguage: 'Español de España',
+ })
+ const restatedContent = resolveSeriesLanguageIntent(splitSpeech!, 'Español', undefined)
+ assert.equal(restatedContent.contentLanguage, 'Español')
+ assert.equal(restatedContent.spokenLanguage, 'Español de España')
+ assert.equal(seriesLanguageIntentAffectsCanon(splitSpeech!, restatedContent), false)
})
test('manual Story and Series language controls update visible and durable fields together', () => {You can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit ee9c1e5. Configure here.

Resultado
Introduce un contrato multidioma durable para que el idioma de la interfaz no decida el idioma de conversación, contenido, voz/letra ni prompts técnicos del proveedor.
languageIntenty lo heredan en adaptaciones y Director.Validación
Integración
La rama incorpora
mainhastaf9148d8y actualmente no está por detrás. El trabajo se realizó en un clon temporal aislado; no se modificó el árbol usado por Grok.Note
Medium Risk
Broad cross-cutting changes to Wizard actions, LLM prompts, and persistence boundaries; incorrect language routing could affect generated media and canon approval, though behavior is heavily tested.
Overview
Introduces a durable
LanguageIntentcontract so UI locale no longer drives Wizard replies, authored content, speech/lyrics, or provider prompts. Creative projects in Story, Series, and Comics now persistlanguageIntent(with backend normalization and legacy migration fromlanguage/spokenLanguage).The Wizard LLM schema gains
conversation_languageand per-actionlanguage_intent; the client merges quoted dialogue/lyrics intoverbatimSegments, tags assistant messages withlang, and compiles Studio/Series/Comic provider prompts in English while keeping reader-facing fields in the requested content language. Song writers and Story Lab generation prompts now require STYLE /visualPromptin English and lyrics or narrative text in the chosen language, with character-for-character protection for literals.Series canon invalidation moves to
series_canon_inputs_changed, which treats production language intent (excluding chat-onlyconversationLanguage) as a canon input. Acceptance adds alanguageWizard scenario, a loopbacktest:wizard-language-livescript, and related docs/tests.Reviewed by Cursor Bugbot for commit ee9c1e5. Configure here.