diff --git a/ui/src/features/agent/agentActions.ts b/ui/src/features/agent/agentActions.ts index 76b72884..44b293ef 100644 --- a/ui/src/features/agent/agentActions.ts +++ b/ui/src/features/agent/agentActions.ts @@ -1843,8 +1843,13 @@ const EXPLICIT_VIDEO_REQUESTS = [ const NEGATED_VIDEO_REQUEST = /\b(?:no|sin|don['’]?t|do\s+not)\b[^.!?\n]{0,32}\b(?:hagas|generes|crees|lances|encoles|hacer|generar|crear|lanzar|encolar|make|create|generate|render|launch|start|queue)\b/i const EXPLICIT_CANCEL_REQUESTS = [ - /\b(?:cancela|cancelad|cancelar|para|parad|det[eé]n|detened)\b[^.!?\n]*\b(?:tarea|trabajo|job|cola|generaci[oó]n|v[ií]deo|video|clip)\b/i, - /\b(?:para|parad|det[eé]n)\b[^.!?\n]*\b(?:lo que est[aá] (?:generando|renderizando|en cola|corriendo))\b/i, + /\b(?:cancela|cancelad|cancelar|parad|det[eé]n|detened)\b[^.!?\n]*\b(?:tarea|trabajo|job|cola|generaci[oó]n|v[ií]deo|video|clip)\b/i, + // "para" is also the Spanish preposition ("pasos para generar un vídeo"). + // Treat it as the imperative of parar only at a sentence start and only + // when the object is a job, never media or an infinitive. + /(?:^|[.!?;]\s*)(?:por favor[, ]+)?para\b(?:\s+(?:ya|ahora))?\s+(?:el|la|este|esta|esa)?\s*(?:tarea|trabajo|job|cola|generaci[oó]n)\b/i, + /\b(?:parad|det[eé]n)\b[^.!?\n]*\b(?:lo que est[aá] (?:generando|renderizando|en cola|corriendo))\b/i, + /(?:^|[.!?;]\s*)(?:por favor[, ]+)?para\b[^.!?\n]{0,24}\blo que est[aá] (?:generando|renderizando|en cola|corriendo)\b/i, /\b(?:cancel|stop|abort)\b[^.!?\n]*\b(?:task|job|queue|generation|video|clip|active)\b/i, ] const NEGATED_CANCEL_REQUEST = /\b(?:no|sin|don['’]?t|do\s+not)\b[^.!?\n]{0,24}\b(?:cancel|cancela|canceles|pares|detengas|stop|abort)\b/i @@ -1870,13 +1875,13 @@ export function isExplicitRetryRequest(request: string): boolean { export function isExplicitVideoGenerationRequest(request: string): boolean { const text = request.trim() - if (!text || NEGATED_VIDEO_REQUEST.test(text)) return false + if (!text || NEGATED_VIDEO_REQUEST.test(text) || isHowToGenerateQuestion(text)) return false return EXPLICIT_VIDEO_REQUESTS.some(pattern => pattern.test(text)) } export function isResumePreparedStudioVideoRequest(request: string): boolean { const text = request.trim() - if (!text || NEGATED_VIDEO_REQUEST.test(text)) return false + if (!text || NEGATED_VIDEO_REQUEST.test(text) || isHowToGenerateQuestion(text)) return false const match = text.match( /\b(?:genera|generad|lanza|lanzad|encola|encolad|env[ií]a|enviad|start|queue|launch)\b[^.!?\n]{0,24}\b(?:el|la|este|esta|the)\s+(?:video|v[ií]deo|clip)\b(.*)$/i, ) @@ -1900,7 +1905,7 @@ const EXPLICIT_IMAGE_REQUESTS = [ export function isExplicitImageGenerationRequest(request: string): boolean { const text = request.trim() - if (!text || NEGATED_VIDEO_REQUEST.test(text)) return false + if (!text || NEGATED_VIDEO_REQUEST.test(text) || isHowToGenerateQuestion(text)) return false if (isExplicitVideoGenerationRequest(text)) return false return EXPLICIT_IMAGE_REQUESTS.some(pattern => pattern.test(text)) } @@ -1920,7 +1925,7 @@ const STUDIO_AUDIO_CONTEXT = [ export function isExplicitAudioGenerationRequest(request: string): boolean { const text = request.trim() if (!text || NEGATED_VIDEO_REQUEST.test(text) || MUSIC_VIDEO_CONTEXT.test(text)) return false - if (isExplicitSfxGenerationRequest(text)) return false + if (isHowToGenerateQuestion(text) || isExplicitSfxGenerationRequest(text)) return false if (!STUDIO_AUDIO_CONTEXT.some(pattern => pattern.test(text))) return false return EXPLICIT_AUDIO_GENERATION_REQUESTS.some(pattern => pattern.test(text)) } @@ -1941,7 +1946,7 @@ const EXPLICIT_3D_REQUESTS = [ export function isExplicit3dGenerationRequest(request: string): boolean { const text = request.trim() - if (!text || NEGATED_VIDEO_REQUEST.test(text)) return false + if (!text || NEGATED_VIDEO_REQUEST.test(text) || isHowToGenerateQuestion(text)) return false if (isExplicitVideoGenerationRequest(text) || isExplicitImageGenerationRequest(text) || isExplicitSfxGenerationRequest(text)) return false return EXPLICIT_3D_REQUESTS.some(pattern => pattern.test(text)) } @@ -2009,14 +2014,21 @@ function comicPanelTarget( const HOW_TO_GENERATE = [ /\b(?:c[oó]mo(?:\s+(?:lo|la|las|los|puedo|se))?\s+(?:genero|generar|lanzo|lanzar|creo|crear|hago|hacer)|how\s+(?:can|do)\s+(?:i|we|you)\s+(?:generate|create|launch|start|make)|how\s+do\s+i\s+(?:generate|create|launch|start|make))\b/i, /\bhow\s+to\s+(?:generate|create|launch|start|make)\b/i, - /\b(?:explain|describe|tell\s+me|show(?:\s+me)?|can\s+you\s+show\s+me)\b[^.!?\n]{0,96}\bhow\s+(?:to|do(?:\s+i)?)\b/i, - /\b(?:what|which)\s+(?:model|provider)\b[^.!?\n]{0,160}\b(?:generate|create|launch|start|make)\b/i, + /\b(?:explain|describe|tell\s+me|show(?:\s+me)?|can\s+you\s+(?:explain|describe|tell\s+me|show\s+me))\b[^.!?\n]{0,96}\b(?:how\s+(?:to|do(?:\s+i)?)|steps?(?:\s+to)?|(?:what|which)\s+happens)\b/i, + /\b(?:what|which)\s+(?:model|provider|steps?)\b[^.!?\n]{0,160}\b(?:generate|create|launch|start|make)\b/i, + /\b(?:before\s+i\s+(?:generate|create|launch|start|make)|what\s+(?:should|do)\s+i\s+(?:configure|set|know|check|prepare))\b/i, + /\b(?:dime|expl[ií]came|descr[ií]beme|mu[eé]strame)\b[^.!?\n]{0,96}\b(?:c[oó]mo|pasos?(?:\s+para)?)\b/i, + /\b(?:qu[eé])\s+pasos?\b[^.!?\n]{0,160}\b(?:gener|cre|lanz)/i, + /\b(?:antes\s+de\s+(?:generar|crear|lanzar)|qu[eé]\s+(?:debo|deber[ií]a)\s+(?:configurar|saber|preparar))\b/i, ] export function isHowToGenerateQuestion(request: string): boolean { const text = request.trim() - if (!text || text.length > 240) return false - return HOW_TO_GENERATE.some(pattern => pattern.test(text)) + if (!text) return false + // Classify from the opening window so a long explanation after + // "how do I generate a video?" stays educational. A later command + // after 240 characters is treated as a separate request. + return HOW_TO_GENERATE.some(pattern => pattern.test(text.slice(0, 240))) } const LABS_INVENTORY = /(?:¿\s*)?(?:qu[eé]\s+puedes\s+hacer|what\s+can\s+you\s+do)(?:\s+(?:en|in|con|with))?\s+(?:el\s+)?(?:series\s+lab|story\s+lab)/i @@ -2145,6 +2157,26 @@ export async function reconcileAgentTurnWithRequest( } : candidate turn = preserveExactEpisodeTitle(turn) + if (isComicLaunchHowQuestion(request, history)) { + return { + reply: [ + 'No hay un botón llamado **Render page**.', + 'El dibujo de las viñetas es **Generate all images** en Comic Director (barra de Comics), o dímelo aquí: **lánzalo**.', + 'Las viñetas entran en la **misma GPU**, una detrás de otra, no en paralelo. No es un segundo motor.', + ].join('\n\n'), + actions: [{ type: 'open_tab', tab: 'comics' }], + } + } + if (isHowToGenerateQuestion(request)) { + return { + ...turn, + actions: turn.actions.filter(action => ( + action.type === 'open_tab' + || action.type === 'open_story_section' + || action.type === 'open_series_section' + )), + } + } if (isResumePreparedStudioVideoRequest(request)) { const existing = turn.actions.find( (action): action is AgentPrepareVideoAction => action.type === 'prepare_video', @@ -2183,29 +2215,9 @@ export async function reconcileAgentTurnWithRequest( actions: [rhythmic3dWorkflow], } } - if (isComicLaunchHowQuestion(request, history)) { - return { - reply: [ - 'No hay un botón llamado **Render page**.', - 'El dibujo de las viñetas es **Generate all images** en Comic Director (barra de Comics), o dímelo aquí: **lánzalo**.', - 'Las viñetas entran en la **misma GPU**, una detrás de otra, no en paralelo. No es un segundo motor.', - ].join('\n\n'), - actions: [{ type: 'open_tab', tab: 'comics' }], - } - } if (isLabsInventoryQuestion(request)) { return { ...turn, actions: [] } } - if (isHowToGenerateQuestion(request)) { - return { - ...turn, - actions: turn.actions.filter(action => ( - action.type === 'open_tab' - || action.type === 'open_story_section' - || action.type === 'open_series_section' - )), - } - } if (!requestAuthorizesEditorialCommit(request)) { const actions = turn.actions.filter(action => !EDITORIAL_COMMIT_TYPES.has(action.type)) if (actions.length !== turn.actions.length) turn = { ...turn, actions } diff --git a/ui/src/features/agent/wizardTurnReport.ts b/ui/src/features/agent/wizardTurnReport.ts index 7f8b45a5..92dc894c 100644 --- a/ui/src/features/agent/wizardTurnReport.ts +++ b/ui/src/features/agent/wizardTurnReport.ts @@ -43,7 +43,8 @@ type Translate = (key: string, options?: Record) => string /** Conservative presentation policy, not an authorization or execution classifier. */ function allowsExplanation(request: string): boolean { - const text = request.trim().replace(/^[¿¡]+/, '') + // JS `\b` is ASCII-only. Fold accents so "Qué" / "por qué" keep a word boundary. + const text = request.trim().replace(/^[¿¡]+/, '').normalize('NFD').replace(/[\u0300-\u036f]/g, '') if (/^(?:hola|hello|hi|gracias|thanks)[\s!.]*$/i.test(text)) return true // An informational prefix does not erase a later imperative in a mixed turn. if (/(?:[,;.!?\n]|\b(?:and|then|also|y|luego|despu[eé]s))\s*(?:(?:please|por favor)[,\s]+)?(?:create|generate|make|update|delete|remove|add|save|export|start|retry|run|open|select|crea\w*|genera\w*|haz\w*|actualiza\w*|elimina\w*|borra\w*|a[nñ]ade\w*|guarda\w*|exporta\w*|inicia\w*|reintenta\w*|ejecuta\w*|abre|selecciona\w*)\b/i.test(text)) return false diff --git a/ui/src/features/agent/wizardWorkflowRuntime.ts b/ui/src/features/agent/wizardWorkflowRuntime.ts index 63f17967..1010488c 100644 --- a/ui/src/features/agent/wizardWorkflowRuntime.ts +++ b/ui/src/features/agent/wizardWorkflowRuntime.ts @@ -859,6 +859,31 @@ export class WizardWorkflowRuntime { } } + /** + * A CAS retry builds a merged snapshot, but `advanceUnlocked` keeps mutating + * the live workflow object captured in `owner.collection`. Adopt remote-only + * siblings into that same collection instead of pointing persist at the clone + * — otherwise the next persist in this advance overwrites those siblings. + */ + private adoptPersistMerge( + owner: { + workspace: string + collection: WizardWorkflowCollection + openSequence: number + }, + merged: WizardWorkflowCollection, + ): void { + const liveById = new Map(owner.collection.workflows.map(workflow => [workflow.workflowId, workflow])) + owner.collection.workflows = merged.workflows.map(workflow => { + const live = liveById.get(workflow.workflowId) + return live && live.updatedAt >= workflow.updatedAt ? live : workflow + }) + owner.collection.revision = merged.revision + if (this.ownsOpen(owner.openSequence, owner.workspace)) { + this.collection = owner.collection + } + } + private async persistAndEmit( workflow: WizardWorkflowRecord, owner = this.capturePersistOwner(), @@ -894,10 +919,8 @@ export class WizardWorkflowRuntime { lastError = error const remote = await this.persistence.load(targetWorkspace) candidate = mergeCollections(candidate, remote) - owner.collection.revision = candidate.revision - if (this.ownsOpen(openSequence, targetWorkspace)) { - this.collection = candidate - } + this.adoptPersistMerge(owner, candidate) + candidate = clone(owner.collection) } } throw lastError instanceof Error ? lastError : new Error('Could not persist Wizard workflow.') diff --git a/ui/src/stores/studioConfigurationSlice.ts b/ui/src/stores/studioConfigurationSlice.ts index 08a95026..a7477788 100644 --- a/ui/src/stores/studioConfigurationSlice.ts +++ b/ui/src/stores/studioConfigurationSlice.ts @@ -133,7 +133,18 @@ export function createStudioConfigurationSlice( setDurationSeconds: requestedSeconds => { const options = get().modelOptions const fps = options?.fps ?? 16 - const minimum = Math.max(1, (options?.frames_minimum || fps) / fps) + // DramaBox (and similar TTS models) declare duration_slider.min=0 so + // that 0 means "auto-derive from the prompt". The video floor of 1s + // must not rewrite that sentinel — Wizard prepare and the Advanced + // slider both call this setter with 0. + const autoDurationAllowed = Boolean( + options?.audio_only + && options.duration_slider + && options.duration_slider.min === 0, + ) + const minimum = autoDurationAllowed + ? 0 + : Math.max(1, (options?.frames_minimum || fps) / fps) const nativeMaximum = options?.frames_maximum ? options.frames_maximum / fps : null const maximum = options?.sliding_window || nativeMaximum == null ? Number.POSITIVE_INFINITY diff --git a/ui/tests/agentActions.test.mjs b/ui/tests/agentActions.test.mjs index 20036fa4..1cfdd79e 100644 --- a/ui/tests/agentActions.test.mjs +++ b/ui/tests/agentActions.test.mjs @@ -1656,6 +1656,37 @@ test('drops cancel_task unless confirm is true and repairs an explicit cancel re assert.equal(repaired.actions[0].confirm, true) }) +test('Spanish para-preposition does not cancel the active GPU task', async () => { + const { isExplicitCancelRequest, reconcileAgentTurnWithRequest } = await import('../src/features/agent/agentActions.ts') + for (const request of [ + 'Dime los pasos para generar un vídeo', + 'Qué pasos sigo para generar un vídeo', + 'Abre Studio para generar un vídeo', + 'Necesito ayuda para lanzar el vídeo', + 'Ajustes para la generación en Studio', + 'Ayuda para la tarea de vídeo', + ]) { + assert.equal(isExplicitCancelRequest(request), false, request) + const turn = await reconcileAgentTurnWithRequest(request, { reply: 'Cancelo.', actions: [] }) + assert.equal(turn.actions.some(action => action.type === 'cancel_task'), false, request) + } + + for (const request of [ + 'para la generación', + 'Para la tarea', + 'por favor para ya la cola', + 'Para lo que está generando', + 'detén el vídeo', + 'cancela el vídeo', + ]) { + assert.equal(isExplicitCancelRequest(request), true, request) + } + for (const request of ['para la generación', 'Para la tarea', 'por favor para ya la cola', 'Para lo que está generando']) { + const turn = await reconcileAgentTurnWithRequest(request, { reply: 'Vale.', actions: [] }) + assert.equal(turn.actions[0].type, 'cancel_task', request) + } +}) + test('requires confirmation for retry and resolves an explicit latest failure request', async () => { const { parseAgentTurn, reconcileAgentTurnWithRequest } = await import('../src/features/agent/agentActions.ts') const unsigned = parseAgentTurn(JSON.stringify({ @@ -1754,6 +1785,54 @@ test('repairs an explicit Studio audio request when the model only prepares it', assert.equal(voice.actions[0].subMode, 'speech') }) +test('educational generate paraphrases do not enqueue Studio generation', async () => { + const { + isHowToGenerateQuestion, + isExplicitVideoGenerationRequest, + isExplicitImageGenerationRequest, + reconcileAgentTurnWithRequest, + } = await import('../src/features/agent/agentActions.ts') + + for (const question of [ + 'Tell me the steps to generate a video in Studio.', + 'What steps do I follow to generate a video?', + 'Before I generate a video, what should I configure in Studio?', + 'Can you explain what happens when you generate a video?', + 'Dime los pasos para generar un vídeo', + 'Qué pasos sigo para generar un vídeo', + ]) { + assert.equal(isHowToGenerateQuestion(question), true, question) + assert.equal(isExplicitVideoGenerationRequest(question), false, question) + const turn = await reconcileAgentTurnWithRequest(question, { + reply: 'I will generate it now.', + actions: [ + { type: 'prepare_video', prompt: 'un mago en la torre' }, + { type: 'start_generation', confirm: true }, + ], + }) + assert.deepEqual(turn.actions.map(action => action.type), [], question) + } + + const imageQuestion = 'Tell me the steps to generate an image in Studio.' + assert.equal(isHowToGenerateQuestion(imageQuestion), true) + assert.equal(isExplicitImageGenerationRequest(imageQuestion), false) + const imageTurn = await reconcileAgentTurnWithRequest(imageQuestion, { + reply: 'I will paint it now.', + actions: [ + { type: 'prepare_image', prompt: 'un gato naranja' }, + { type: 'start_generation', confirm: true }, + ], + }) + assert.deepEqual(imageTurn.actions.map(action => action.type), []) + + const command = await reconcileAgentTurnWithRequest('Generate a video of a wizard on a tower', { + reply: '¿De qué?', + actions: [], + }) + assert.deepEqual(command.actions.map(action => action.type), ['prepare_video', 'start_generation']) + assert.ok(String(command.actions[0].prompt).includes('wizard')) +}) + test('keeps Story Lab song generation out of the Studio Audio shortcut', async () => { const { isExplicitAudioGenerationRequest, reconcileAgentTurnWithRequest } = await import('../src/features/agent/agentActions.ts') const configure = { @@ -1985,6 +2064,71 @@ test('genera el video de un mapache uses the new topic instead of the prepared f useStore.setState(original) }) +test('how-to launch questions do not queue a prepared Studio video', async () => { + const { useStore } = await import('../src/stores/useStore.ts') + const { + isHowToGenerateQuestion, + isResumePreparedStudioVideoRequest, + isExplicitVideoGenerationRequest, + isExplicitAudioGenerationRequest, + reconcileAgentTurnWithRequest, + } = await import('../src/features/agent/agentActions.ts') + const original = { + generationMode: useStore.getState().generationMode, + params: useStore.getState().params, + savedPromptPerMode: useStore.getState().savedPromptPerMode, + } + useStore.setState({ + generationMode: 'video', + params: { ...useStore.getState().params, prompt: 'un mago en la torre' }, + savedPromptPerMode: { ...useStore.getState().savedPromptPerMode, video: 'un mago en la torre' }, + }) + + try { + for (const question of [ + 'How do I launch the video?', + 'How do I start the video?', + 'Explain how to launch the video', + ]) { + assert.equal(isHowToGenerateQuestion(question), true, question) + assert.equal(isResumePreparedStudioVideoRequest(question), false, question) + assert.equal(isExplicitVideoGenerationRequest(question), false, question) + const turn = await reconcileAgentTurnWithRequest(question, { + reply: 'I will launch it.', + actions: [{ type: 'prepare_video', prompt: 'un mago en la torre' }, { type: 'start_generation', confirm: true }], + }) + assert.deepEqual(turn.actions.map(action => action.type), [], question) + } + + const longVideo = `How do I generate a video? ${'Please explain the workflow in detail. '.repeat(8)}` + assert.ok(longVideo.length > 240) + assert.equal(isHowToGenerateQuestion(longVideo), true) + assert.equal(isExplicitVideoGenerationRequest(longVideo), false) + const longVideoTurn = await reconcileAgentTurnWithRequest(longVideo, { reply: 'Here is how.', actions: [] }) + assert.deepEqual(longVideoTurn.actions.map(action => action.type), []) + + const longAudio = `How can I generate music in Studio Audio? ${'Please explain the workflow in detail. '.repeat(8)}` + assert.ok(longAudio.length > 240) + assert.equal(isHowToGenerateQuestion(longAudio), true) + assert.equal(isExplicitAudioGenerationRequest(longAudio), false) + const longAudioTurn = await reconcileAgentTurnWithRequest(longAudio, { + reply: 'I will generate it now.', + actions: [ + { type: 'open_tab', tab: 'audio' }, + { type: 'prepare_audio', subMode: 'music', prompt: 'Piano' }, + { type: 'start_generation', confirm: true }, + ], + }) + assert.deepEqual(longAudioTurn.actions.map(action => action.type), ['open_tab']) + + const launch = await reconcileAgentTurnWithRequest('lanza el vídeo', { reply: '¿De qué?', actions: [] }) + assert.deepEqual(launch.actions.map(action => action.type), ['prepare_video', 'start_generation']) + assert.equal(launch.actions[0].prompt, 'un mago en la torre') + } finally { + useStore.setState(original) + } +}) + test('genera el video does not copy an incompatible I2V, audio or 3D model as T2V', async () => { const { useStore } = await import('../src/stores/useStore.ts') const { reconcileAgentTurnWithRequest } = await import('../src/features/agent/agentActions.ts') diff --git a/ui/tests/architectureSlices.test.mjs b/ui/tests/architectureSlices.test.mjs index cc04c10f..ba5cb2cf 100644 --- a/ui/tests/architectureSlices.test.mjs +++ b/ui/tests/architectureSlices.test.mjs @@ -393,6 +393,48 @@ test('Studio configuration slice owns form state without owning generation execu assert.equal('runTool' in studio, false) }) +test('Studio duration setter keeps TTS auto sentinel at zero', async () => { + const { createStudioConfigurationSlice } = await import('../src/stores/studioConfigurationSlice.ts') + let state = { + params: { prompt: 'Speaker 1: hello', image_mode: 0 }, + modelOptions: { + fps: 16, + audio_only: true, + duration_slider: { label: 'Target Duration (seconds, 0 = auto)', min: 0, max: 120, increment: 0.5, default: 0 }, + }, + h3WindowPlan: null, + generationMode: 'audio', + savedParamsPerMode: {}, + } + const set = update => { + const partial = typeof update === 'function' ? update(state) : update + state = { ...state, ...partial } + } + const studio = createStudioConfigurationSlice({ + alignFrameCount: frames => frames, + resolveResolution: (_options, preset, ratio) => `${preset}:${ratio}`, + })(set, () => state) + state = { ...state, ...studio } + + state.setDurationSeconds(0) + assert.equal(state.durationSeconds, 0) + assert.equal(state.params.video_length, 0) + + state.setDurationSeconds(12) + assert.equal(state.durationSeconds, 12) + + state.modelOptions = { + fps: 24, + frames_minimum: 24, + frames_maximum: 120, + sliding_window: true, + audio_only: false, + duration_slider: null, + } + state.setDurationSeconds(0) + assert.equal(state.durationSeconds, 1) +}) + test('composed slices bind without as-never casts at the useStore call site', async () => { const fs = await import('node:fs/promises') const source = await fs.readFile(new URL('../src/stores/useStore.ts', import.meta.url), 'utf8') diff --git a/ui/tests/wizardTurnReport.test.ts b/ui/tests/wizardTurnReport.test.ts index 253a44a7..743777ec 100644 --- a/ui/tests/wizardTurnReport.test.ts +++ b/ui/tests/wizardTurnReport.test.ts @@ -101,6 +101,11 @@ test('informational conversation is preserved while navigation requires its own const reply = 'Collections group existing assets.' assert.equal(formatWizardTurnReply({ reply, actions: [] }, [], t, 'What are collections?'), reply) assert.equal(formatWizardTurnReply({ reply, actions: [] }, [], t, '¿Cómo funcionan las colecciones?'), reply) + // Accented qué/por qué used to fail JS `\b` and show a false empty-turn receipt. + assert.equal(formatWizardTurnReply({ reply, actions: [] }, [], t, 'Qué son las colecciones?'), reply) + assert.equal(formatWizardTurnReply({ reply, actions: [] }, [], t, '¿Qué son las colecciones?'), reply) + assert.equal(formatWizardTurnReply({ reply, actions: [] }, [], t, 'Por qué no aparecen las colecciones?'), reply) + assert.equal(formatWizardTurnReply({ reply, actions: [] }, [], t, '¿Por qué falló la generación?'), reply) assert.match(formatWizardTurnReply({ reply, actions: [{ type: 'open_tab', tab: 'workspaces' }] }, [], t), /No action was executed/) }) @@ -110,7 +115,7 @@ test('empty and omitted actions never claim creation in response to an action re for (const request of ['Create Nightwatch using my settings.', 'Can you create a collection?', 'Crea un proyecto Nightwatch.', 'Hola, crea un proyecto Nightwatch.', 'Hi, create Nightwatch.', 'Can you explain collections and create Nightwatch?', 'What are collections? Create one named Nightwatch.', - 'Explica las colecciones y crea Nightwatch.', '']) { + 'Explica las colecciones y crea Nightwatch.', 'Qué son las colecciones? Crea una llamada Nightwatch.', '']) { const reply = formatWizardTurnReply(turn, [], t, request) assert.match(reply, /No action was executed/) assert.doesNotMatch(reply, /invented-999|Created/) diff --git a/ui/tests/wizardWorkflowRuntime.test.mjs b/ui/tests/wizardWorkflowRuntime.test.mjs index 46268b3b..5186c260 100644 --- a/ui/tests/wizardWorkflowRuntime.test.mjs +++ b/ui/tests/wizardWorkflowRuntime.test.mjs @@ -61,6 +61,11 @@ function workspaceMemoryPersistence() { holdSave?.release() holdSave = null }, + injectWorkflow(workspace, workflow) { + const collection = store(workspace) + collection.workflows = [...collection.workflows, clone(workflow)] + collection.revision += 1 + }, } } @@ -434,3 +439,69 @@ test('a step that finishes after open() still persists to the source workspace', assert.equal(runtime.get('workflow-a'), undefined) assert.equal(runtime.get('workflow-b').workspace, 'workspace-b') }) + +test('a CAS merge during advance keeps sibling workflows on later persists', async () => { + const { WizardWorkflowRuntime } = await import('../src/features/agent/wizardWorkflowRuntime.ts') + const persistence = workspaceMemoryPersistence() + const runtime = new WizardWorkflowRuntime(persistence) + runtime.register({ + type: 'keeper', + steps: [{ + stepId: 'wait', kind: 'wait for task', + async execute() { return { state: 'waiting', taskId: 'task-keep' } }, + }], + }) + runtime.register({ + type: 'two_step', + steps: [{ + stepId: 'first', kind: 'first step', + async execute() { + persistence.injectWorkflow('demo', { + workflowId: 'workflow-other-tab', + type: 'keeper', + workspace: 'demo', + userRequest: 'Added by another tab', + state: 'completed', + currentStep: 1, + steps: [], + resolvedEntityIds: {}, + inputSnapshot: {}, + taskIds: [], + pipelineIds: [], + outputRefs: [], + confirmationScope: [], + processedEventIds: [], + attempts: 0, + createdAt: 1, + updatedAt: Date.now(), + recoverableError: '', + cancelRequested: false, + resumeRequested: false, + pendingInput: null, + }) + return { state: 'completed', outputRefs: ['first.bin'] } + }, + }, { + stepId: 'second', kind: 'second step', + async execute() { return { state: 'completed', outputRefs: ['second.bin'] } }, + }], + }) + + await runtime.open('demo') + await runtime.start({ + workflowId: 'workflow-keep', type: 'keeper', workspace: 'demo', + userRequest: 'Keep the existing checkpoint', + }) + await runtime.start({ + workflowId: 'workflow-advance', type: 'two_step', workspace: 'demo', + userRequest: 'Finish both steps after a sibling merge', + }) + + const storedIds = persistence.snapshot('demo').workflows.map(item => item.workflowId).sort() + assert.deepEqual(storedIds, ['workflow-advance', 'workflow-keep', 'workflow-other-tab']) + assert.equal(runtime.get('workflow-keep').state, 'waiting') + assert.equal(runtime.get('workflow-other-tab').state, 'completed') + const advanced = runtime.get('workflow-advance') + assert.equal(advanced.state, 'completed') + assert.deepEqual(advanced.outputRefs, ['first.bin', 'second.bin']) +})