Skip to content

fix(wizard): recover safely from conversation conflicts - #122

Merged
IAnMove merged 20 commits into
mainfrom
fix/wizard-conversation-conflicts
Sep 3, 2026
Merged

fix(wizard): recover safely from conversation conflicts#122
IAnMove merged 20 commits into
mainfrom
fix/wizard-conversation-conflicts

Conversation

@IAnMove

@IAnMove IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Summary

  • preserve exact Wizard turns when two tabs race on the conversation CAS revision
  • retry one and only one structured 409 after refetching and deterministically merging the canonical snapshot
  • keep non-recoverable 4xx and a second conflict visible in the Wizard without retry loops
  • keep in-flight conversation writes isolated when the active workspace changes

Validation

  • cd ui && npx tsx --tsconfig tsconfig.app.json --import ./tests/setupI18n.ts --test tests/wizardConversationPersistence.test.mjs
  • cd ui && npm run check
  • 560 UI tests passed
  • i18n catalogs, ESLint, TypeScript/Vite build and bundle budget passed

No providers, models or generation tokens used. Based on origin/main at 0d5b077 (#121); no backend launcher or hot agent/action files were changed.


Note

Medium Risk
Complex client-side merge and CAS logic for durable Wizard state; regressions could drop messages, mishandle clears, or mis-sync across tabs/workspaces, though coverage is extensive in new tests.

Overview
Replaces ad-hoc Wizard conversation saves with serialized, workspace-keyed persistence that handles multi-tab races, in-flight edits, and explicit clears without losing turns or deleting history outside the UI window.

The API layer now throws WizardConversationRequestError with HTTP status and structured detail so 409 revision conflicts are distinguished from validation/auth failures. Saves run through saveWizardConversationWithRecovery (one refetch + merge + retry, no loops on a second 409) and three-way merges when a queued write has a recorded base—including Clear, which alone may honor local deletes.

AgentAssistantPanel tracks per-workspace snapshots, chains saves, rebases stale hydration and post-save UI state, delays workspace switches while a turn is busy, and surfaces conversationSaveError in the panel. Sync helpers add deterministic message merging and guards for stale async writes.

Reviewed by Cursor Bugbot for commit c4b627f. Configure here.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

PR Review — Loreframe Studio

Risk: medium
Scope: 7 file(s); +1165/-70; React UI

Automated review from scripts/analyze_pr.py. This is a heuristic pass (no LLM) so humans still own the merge decision.

Findings

  • medium — Very large file change (ui/tests/wizardConversationPersistence.test.mjs)
    ui/tests/wizardConversationPersistence.test.mjs adds 608 lines. Consider splitting the PR.
  • low — UI changed — rebuild before merge
    Run cd ui && npm run build (CI already does this). Pinokio Update rebuilds for end users; keep ui/dist untracked.

Changed files

  • added: ui/src/features/agent/wizardConversationPersistence.ts, ui/tests/wizardConversationPersistence.test.mjs
  • modified: ui/src/api/wizard.ts, ui/src/features/agent/AgentAssistantPanel.tsx, ui/src/features/agent/wizardConversationSync.ts, ui/src/i18n/locales/en/wizard.json, ui/src/i18n/locales/es/wizard.json

CONTRIBUTING checklist

  • python scripts/verify_clean_repo.py
  • python -m compileall -q app/services app/launch.py scripts
  • cd ui && npm run build if the UI changed
  • No weights, CivitAI sidecars, or generated guides
  • Stays local-first (no required accounts / telemetry)

Posted by the repo PR review workflow. Re-runs on each push to the PR.

@IAnMove

IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

@cursor review

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Code health

Quality score: 49.1/100

Higher is better. The score is a trend dashboard; the independent ratchet below remains the CI gate.

Component Weight Current Change
Cyclomatic health 45% 52.3 +0.1
File concentration 25% 54.0 +0.1
Oversized-file debt 20% 29.2 +0.2
Modularity 10% 61.9 +0.0

Change vs PR base: +0.2 points.

Metric Value
Production LOC 239,699
Production files 525
Test LOC 68,018
Functions measured 15,000
Functions complexity ≥ 15 779
Maximum complexity 667

Markdown, JSON catalogs and tests are out of this table. Only app/ runtime + ui/src TS/JS count.

Most complex functions

Complexity Where
667 app/wgp.py:7157 generate_video
374 ui/src/stores/useStore.ts:3952 Async method 'startGeneration'
355 app/_launch_runtime.py:23257 _run_generation
308 app/wgp.py:12274 generate_video_tab
271 ui/src/components/Sidebar/SceneAnimatorPanel.tsx:474 Function 'SceneAnimatorPanel'
266 ui/src/stores/useStore.ts:8494 Async method 'loadSettingsFromOutput'
258 app/services/director/planners/short_film.py:3433 ShortFilmPlanner._plan_story_driven
248 app/services/director_pipeline.py:13735 _run_video_generation
245 app/services/director_pipeline.py:7860 _run_pipeline
243 ui/src/features/agent/agentActions.ts:1105 Function 'parseAction'
226 app/services/director_pipeline.py:6689 update_comic_preview
225 ui/src/features/agent/agentActions.ts:2755 Async function 'executeAgentActions'

Trend vs baseline

Metric Δ
Production LOC +6,034
Test LOC +4,204
Functions ≥ 15 +5
Maximum complexity +0

Warnings

  • production LOC increased by +6,034
  • functions at complexity >= 15 increased by +5
  • complexity hotspot ui/src/stores/useStore.ts rose 373 -> 374
  • complexity hotspot ui/src/features/agent/agentActions.ts rose 241 -> 243
  • complexity hotspot ui/src/components/Sidebar/InputsPanel.tsx rose 157 -> 162
  • complexity hotspot ui/src/components/MainContent/MediaFeedItem.tsx rose 120 -> 122
  • complexity hotspot ui/src/features/comics/ComicEditorPanel.tsx rose 101 -> 103
  • complexity hotspot ui/src/components/Sidebar/PromptInput.tsx rose 68 -> 70
  • complexity hotspot ui/src/features/series/SeriesSetupPanel.tsx rose 58 -> 62
  • complexity hotspot app/services/model3d_service.py rose 51 -> 54
  • complexity hotspot ui/src/features/series/actions.ts rose 47 -> 50
  • complexity hotspot ui/src/features/comics/generateArtwork.ts rose 42 -> 44
  • complexity hotspot ui/src/components/MainContent/TabFilter.tsx rose 19 -> 23
  • complexity hotspot ui/src/features/agent/AgentAssistantPanel.tsx rose 19 -> 23
  • complexity hotspot ui/src/features/projects/openProject.ts rose 21 -> 22
  • complexity hotspot app/services/story_library.py rose 16 -> 19
  • complexity hotspot ui/src/components/Sidebar/ControlVideoSection.tsx rose 15 -> 17
  • hotspot app/services/director_pipeline.py increased by +186 lines
  • hotspot ui/src/features/agent/agentActions.ts increased by +64 lines
  • hotspot ui/src/components/Sidebar/SceneAnimatorPanel.tsx increased by +6 lines
  • hotspot ui/src/features/video-editor/VideoEditorPanel.tsx increased by +27 lines
  • hotspot ui/src/features/comics/ComicWorkflowPanels.tsx increased by +6 lines
  • hotspot ui/src/features/comics/ComicEditorPanel.tsx increased by +45 lines
  • hotspot ui/src/types/index.ts increased by +3 lines
  • hotspot ui/src/features/stories/actions.ts increased by +71 lines
  • hotspot app/services/series_library.py increased by +36 lines
  • hotspot app/services/model3d_service.py increased by +58 lines
  • hotspot ui/src/components/Sidebar/InputsPanel.tsx increased by +32 lines
  • hotspot ui/src/components/DirectorDashboard/DirectorDashboard.tsx increased by +17 lines
  • hotspot ui/src/features/agent/capabilityRegistry.ts increased by +67 lines
  • hotspot ui/src/features/agent/applicationAdapters.ts increased by +30 lines

Ratchet passed.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: Recovery overwrites newer conversation turns
    • Tras un merge por conflicto CAS, solo se omite el siguiente guardado cuando el snapshot canónico ya incluye todos los turnos visibles, permitiendo que los turnos más nuevos se persistan de inmediato.
  • ✅ Fixed: Busy isolation mixes workspace events
    • Las suscripciones de workflow, el reseteo de pending input y el polling de tareas ahora usan conversationWorkspace y se omiten cuando difiere del workspace activo durante busy.

Create PR

Or push these changes by commenting:

@cursor push f2eab0fac2
Preview (f2eab0fac2)
diff --git a/ui/src/features/agent/AgentAssistantPanel.tsx b/ui/src/features/agent/AgentAssistantPanel.tsx
--- a/ui/src/features/agent/AgentAssistantPanel.tsx
+++ b/ui/src/features/agent/AgentAssistantPanel.tsx
@@ -183,7 +183,7 @@
     let active = true
     ensureRhythmic3dWorkflowRegistered(defaultApplicationAdapters)
     const unsubscribe = defaultWizardWorkflowRuntime.subscribe(({ workflow, card }) => {
-      if (!active || workflow.workspace !== workspace) return
+      if (!active || workflow.workspace !== conversationWorkspace) return
       setActiveWorkflow(workflow)
       setPendingInput(workflow.state === 'awaiting_input' ? workflow.pendingInput : null)
       setMessages(current => {
@@ -207,10 +207,10 @@
         }].slice(-40)
       })
     })
-    void defaultWizardWorkflowRuntime.open(workspace).catch(() => {
+    void defaultWizardWorkflowRuntime.open(conversationWorkspace).catch(() => {
       // Existing immediate actions remain available if workflow storage is offline.
     })
-    const closeEvents = subscribeCanonicalTaskEvents(workspace, event => {
+    const closeEvents = subscribeCanonicalTaskEvents(conversationWorkspace, event => {
       void defaultWizardWorkflowRuntime.handleTaskEvent(event).catch(() => {
         // The checkpoint stays recoverable; a reconnect replays the same event.
       })
@@ -220,12 +220,12 @@
       unsubscribe()
       closeEvents()
     }
-  }, [workspace])
+  }, [conversationWorkspace])
 
   useEffect(() => {
     setActiveWorkflow(null)
     setPendingInput(null)
-  }, [workspace])
+  }, [conversationWorkspace])
 
   useEffect(() => {
     writeMessages(conversationWorkspace, messages)
@@ -251,8 +251,12 @@
       conversationRevisionRef.current = saved.conversation.revision
       setConversationSaveError(null)
       if (saved.merged) {
-        skipNextConversationSaveRef.current = true
         const canonicalMessages = normalizeRemoteWizardMessages(saved.conversation.messages, saved.conversation.executions)
+        const canonicalIds = new Set(canonicalMessages.map(message => message.id))
+        const hasExclusiveLocal = messagesRef.current.some(message => !canonicalIds.has(message.id))
+        if (!hasExclusiveLocal) {
+          skipNextConversationSaveRef.current = true
+        }
         setMessages(mergeWizardMessages(messagesRef.current, canonicalMessages) as AgentMessage[])
       }
     }).catch(error => {
@@ -306,6 +310,7 @@
   }, [busy, conversationWorkspace, workspace])
 
   useEffect(() => {
+    if (workspace !== conversationWorkspace) return
     setMessages(current => current.map(message => {
       if (!message.cards?.length) return message
       let changed = false
@@ -334,7 +339,7 @@
       })
       return changed ? { ...message, cards } : message
     }))
-  }, [tasks])
+  }, [conversationWorkspace, tasks, workspace])
 
   useEffect(() => {
     const closeOnEscape = (event: KeyboardEvent) => {

You can send follow-ups to the cloud agent here.

Comment thread ui/src/features/agent/AgentAssistantPanel.tsx
Comment thread ui/src/features/agent/AgentAssistantPanel.tsx
@IAnMove

IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

@cursor review

Comment thread ui/src/features/agent/AgentAssistantPanel.tsx Outdated
@IAnMove

IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

@cursor review

Comment thread ui/src/features/agent/AgentAssistantPanel.tsx Outdated
Comment thread ui/src/features/agent/AgentAssistantPanel.tsx Outdated
@IAnMove

IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: Queued persist merge undoes local updates
    • persistQueuedWizardConversation now adopts only the latest confirmed revision and writes the captured conversation as-is, so a local clear or in-place card edit is no longer unioned back into the previous snapshot.

Create PR

Or push these changes by commenting:

@cursor push 92841a541e
Preview (92841a541e)
diff --git a/ui/src/features/agent/wizardConversationPersistence.ts b/ui/src/features/agent/wizardConversationPersistence.ts
--- a/ui/src/features/agent/wizardConversationPersistence.ts
+++ b/ui/src/features/agent/wizardConversationPersistence.ts
@@ -109,11 +109,15 @@
 }
 
 /**
- * Persist one queued snapshot against the latest canonical state known for its
- * workspace. Queued React effects intentionally capture their visible turn,
- * but must not capture the revision/canonical payload: an earlier queued save
- * may advance both before this write starts.
+ * Persist one queued snapshot against the latest confirmed revision known for
+ * its workspace. Queued React effects capture the visible conversation, but
+ * must not capture the revision: an earlier queued save may advance it before
+ * this write starts.
  *
+ * The captured conversation is written as-is. Snapshot merge is reserved for a
+ * real CAS 409; unioning with the last snapshot on every queued write would
+ * restore turns a local clear or in-place edit had already replaced.
+ *
  * The store is keyed by workspace so changing the visible workspace never
  * rebinds or drops a write that was already accepted into the queue.
  */
@@ -125,7 +129,7 @@
 ): Promise<WizardConversationSaveResult> {
   const canonical = snapshots.get(workspace)
   const outgoing = canonical
-    ? mergeWizardConversationSnapshots(captured, canonical)
+    ? { ...captured, revision: canonical.revision }
     : captured
   const saved = await saveWizardConversationWithRecovery(workspace, outgoing, transport)
   snapshots.set(workspace, saved.conversation)

diff --git a/ui/tests/wizardConversationPersistence.test.mjs b/ui/tests/wizardConversationPersistence.test.mjs
--- a/ui/tests/wizardConversationPersistence.test.mjs
+++ b/ui/tests/wizardConversationPersistence.test.mjs
@@ -226,7 +226,7 @@
   assert.equal(revision, 2)
 })
 
-test('a stale queued snapshot merges the canonical turn saved by its predecessor', async () => {
+test('a stale queued snapshot adopts the predecessor revision and writes the captured conversation', async () => {
   let canonical = payload(0, [])
   const snapshots = new Map()
   const savedPayloads = []
@@ -241,13 +241,13 @@
   }
 
   const firstVisible = payload(0, ['first-user', 'first-assistant'])
-  const staleSecondEffect = payload(0, ['second-user', 'second-assistant'])
+  const laterVisible = payload(0, ['first-user', 'first-assistant', 'second-user', 'second-assistant'])
   let chain = Promise.resolve()
   chain = enqueueWizardConversationSave(chain, () => (
     persistQueuedWizardConversation('workspace-a', firstVisible, snapshots, transport).then(() => undefined)
   ))
   chain = enqueueWizardConversationSave(chain, () => (
-    persistQueuedWizardConversation('workspace-a', staleSecondEffect, snapshots, transport).then(() => undefined)
+    persistQueuedWizardConversation('workspace-a', laterVisible, snapshots, transport).then(() => undefined)
   ))
   await chain
 
@@ -258,6 +258,42 @@
   assert.deepEqual(snapshots.get('workspace-a'), canonical)
 })
 
+test('a queued persist does not restore snapshot turns after a local replacement or edit', async () => {
+  let canonical = payload(0, [])
+  const snapshots = new Map()
+  const savedPayloads = []
+  const transport = {
+    async fetch() { return clone(canonical) },
+    async save(_workspace, conversation) {
+      assert.equal(conversation.revision, canonical.revision)
+      savedPayloads.push(clone(conversation))
+      canonical = { ...clone(conversation), revision: canonical.revision + 1 }
+      return clone(canonical)
+    },
+  }
+
+  const prior = payload(0, ['old-user', 'old-assistant'], 'old-')
+  const cleared = payload(0, ['welcome-after-clear'])
+  const editedWelcome = {
+    ...cleared,
+    messages: [{
+      ...cleared.messages[0],
+      text: 'updated welcome',
+      cards: [{ id: 'card-1', state: 'completed' }],
+    }],
+    executions: [{ id: 'card-1', state: 'completed' }],
+  }
+
+  await persistQueuedWizardConversation('workspace-a', prior, snapshots, transport)
+  await persistQueuedWizardConversation('workspace-a', cleared, snapshots, transport)
+  await persistQueuedWizardConversation('workspace-a', editedWelcome, snapshots, transport)
+
+  assert.deepEqual(savedPayloads[1].messages.map(message => message.id), ['welcome-after-clear'])
+  assert.equal(savedPayloads[2].messages[0].text, 'updated welcome')
+  assert.deepEqual(savedPayloads[2].messages[0].cards, [{ id: 'card-1', state: 'completed' }])
+  assert.equal(savedPayloads[2].revision, 2)
+})
+
 test('a queued write persists to its captured workspace after the visible workspace changes', async () => {
   const snapshots = new Map()
   const savedWorkspaces = []

You can send follow-ups to the cloud agent here.

Comment thread ui/src/features/agent/wizardConversationPersistence.ts
@IAnMove

IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

@cursor review

@IAnMove

IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: Stale fetch overwrites newer snapshot
    • Hydration now writes the fetched payload into conversationSnapshotsRef only when its revision is at least as new as the known snapshot, so a raced fetch cannot replace a confirmed CAS revision.

Create PR

Or push these changes by commenting:

@cursor push 2b1e38ed69
Preview (2b1e38ed69)
diff --git a/ui/src/features/agent/AgentAssistantPanel.tsx b/ui/src/features/agent/AgentAssistantPanel.tsx
--- a/ui/src/features/agent/AgentAssistantPanel.tsx
+++ b/ui/src/features/agent/AgentAssistantPanel.tsx
@@ -287,7 +287,12 @@
         remoteRevision: payload.revision || 0,
         remoteExecutions: payload.executions,
       })
-      conversationSnapshotsRef.current.set(conversationWorkspace, payload)
+      // A fetch that raced an in-flight save must not replace a newer
+      // confirmed CAS snapshot; later queued writes use this map as
+      // their baseRevision and recovery retries only one 409.
+      if ((payload.revision || 0) >= (knownSnapshot?.revision || 0)) {
+        conversationSnapshotsRef.current.set(conversationWorkspace, payload)
+      }
       skipNextConversationSaveRef.current = choice.source === 'remote'
       // A local choice may still adopt the backend's newer CAS revision and
       // merge remote-only messages. Use a fresh array so the persistence

You can send follow-ups to the cloud agent here.

Comment thread ui/src/features/agent/AgentAssistantPanel.tsx Outdated
@IAnMove

IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

@cursor review

Comment thread ui/src/features/agent/AgentAssistantPanel.tsx Outdated
@IAnMove

IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

@cursor review

Comment thread ui/src/features/agent/AgentAssistantPanel.tsx
@IAnMove

IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: Rebase treats shape diffs as edits
    • stableKey now ignores empty optional fields so UI messages and server payloads compare as equal, allowing stale hydration to skip redundant CAS writes and keep confirmed shared-id updates.

Create PR

Or push these changes by commenting:

@cursor push b07e7070b6
Preview (b07e7070b6)
diff --git a/ui/src/features/agent/wizardConversationPersistence.ts b/ui/src/features/agent/wizardConversationPersistence.ts
--- a/ui/src/features/agent/wizardConversationPersistence.ts
+++ b/ui/src/features/agent/wizardConversationPersistence.ts
@@ -67,11 +67,15 @@
   save: saveWizardConversation,
 }
 
+function isEmptyStableValue(value: unknown): boolean {
+  return value == null || value === '' || (Array.isArray(value) && value.length === 0)
+}
+
 function stableKey(value: unknown): string {
   if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? String(value)
   if (Array.isArray(value)) return `[${value.map(stableKey).join(',')}]`
   const record = value as Record<string, unknown>
-  return `{${Object.keys(record).sort().map(key => `${JSON.stringify(key)}:${stableKey(record[key])}`).join(',')}}`
+  return `{${Object.keys(record).sort().filter(key => !isEmptyStableValue(record[key])).map(key => `${JSON.stringify(key)}:${stableKey(record[key])}`).join(',')}}`
 }
 
 function mergeUniqueValues(remote: unknown, local: unknown): unknown[] {

diff --git a/ui/tests/wizardConversationPersistence.test.mjs b/ui/tests/wizardConversationPersistence.test.mjs
--- a/ui/tests/wizardConversationPersistence.test.mjs
+++ b/ui/tests/wizardConversationPersistence.test.mjs
@@ -404,3 +404,48 @@
   assert.equal(rebased.needsPersist, false)
   assert.deepEqual(rebased.conversation.messages, confirmed.messages)
 })
+
+test('UI and server message shape diffs are not treated as local edits', () => {
+  const serverMessage = (id, role, text, createdAt, extra = {}) => ({
+    id,
+    role,
+    text,
+    createdAt,
+    cards: [],
+    executionKey: '',
+    jobLinks: [],
+    lastState: '',
+    error: '',
+    ...extra,
+  })
+  const stale = {
+    version: 1,
+    revision: 7,
+    messages: [serverMessage('shared-user', 'user', 'hello', 1)],
+    executions: [],
+  }
+  const confirmed = {
+    version: 1,
+    revision: 8,
+    messages: [
+      serverMessage('shared-user', 'user', 'hello', 1, { executionKey: 'server-key' }),
+      serverMessage('confirmed-assistant', 'assistant', 'reply', 2),
+    ],
+    executions: [],
+  }
+  const visible = {
+    version: 1,
+    revision: 7,
+    messages: [{ id: 'shared-user', role: 'user', text: 'hello', createdAt: 1 }],
+    executions: [],
+  }
+
+  const rebased = rebaseStaleWizardConversationHydration(visible, stale, confirmed)
+
+  assert.equal(rebased.needsPersist, false)
+  assert.equal(rebased.conversation.messages[0].executionKey, 'server-key')
+  assert.deepEqual(rebased.conversation.messages.map(message => message.id), [
+    'shared-user',
+    'confirmed-assistant',
+  ])
+})

You can send follow-ups to the cloud agent here.

Comment thread ui/src/features/agent/wizardConversationPersistence.ts
@IAnMove

IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

@cursor review

Comment thread ui/src/features/agent/AgentAssistantPanel.tsx Outdated
Comment thread ui/src/features/agent/AgentAssistantPanel.tsx
@IAnMove

IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

@cursor review

Comment thread ui/src/features/agent/AgentAssistantPanel.tsx
@IAnMove

IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Clear persist drops recorded ancestor
    • Clear persist now merges against the recorded visible ancestor so queued-but-unsaved turns are treated as local deletes before the ancestor is dropped.

Create PR

Or push these changes by commenting:

@cursor push 4608a381b9
Preview (4608a381b9)
diff --git a/ui/src/features/agent/AgentAssistantPanel.tsx b/ui/src/features/agent/AgentAssistantPanel.tsx
--- a/ui/src/features/agent/AgentAssistantPanel.tsx
+++ b/ui/src/features/agent/AgentAssistantPanel.tsx
@@ -244,12 +244,12 @@
       messages,
       executions: cards,
     }
+    const queuedClearBase = conversationClearBasesRef.current.get(conversationWorkspace)
     const queuedWrite = {
       workspace: conversationWorkspace,
       captured: capturedConversation,
-      base: conversationSnapshotsRef.current.get(conversationWorkspace),
+      base: queuedClearBase ?? conversationSnapshotsRef.current.get(conversationWorkspace),
     }
-    const queuedClearBase = conversationClearBasesRef.current.get(conversationWorkspace)
     conversationSaveChainRef.current = enqueueWizardConversationSave(
       conversationSaveChainRef.current,
       async () => {

You can send follow-ups to the cloud agent here.

Comment thread ui/src/features/agent/AgentAssistantPanel.tsx
@IAnMove

IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

@cursor review

@IAnMove

IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

There are 5 total unresolved issues (including 2 from previous reviews).

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Post-merge save drops recovered turns
    • Non-clear persists no longer treat the 40-message UI window as deletes, and exclusivity is checked against the full saved snapshot so recovered turns stay on the server.
  • ✅ Fixed: Conflict retry resurrects cleared turns
    • The 409 retry now three-way-merges against the queued write's recorded ancestor, so a predecessor clear still classifies those ids as deletes.
  • ✅ Fixed: Shared-id merge discards local updates
    • mergeWizardMessages and remote hydration now keep the newer local copy of a shared id so in-flight card and workflow updates are not overwritten.

Create PR

Or push these changes by commenting:

@cursor push 904b7d2a13
Preview (904b7d2a13)
diff --git a/ui/src/features/agent/AgentAssistantPanel.tsx b/ui/src/features/agent/AgentAssistantPanel.tsx
--- a/ui/src/features/agent/AgentAssistantPanel.tsx
+++ b/ui/src/features/agent/AgentAssistantPanel.tsx
@@ -258,6 +258,9 @@
       // A clear is a three-way delete relative to the exact conversation the
       // user saw, even if an earlier queued save advances the canonical state.
       base: queuedClearBase ?? conversationSnapshotsRef.current.get(conversationWorkspace),
+      // The panel only holds 40 messages; older recovered ids must not look
+      // like local deletes unless the user explicitly cleared the chat.
+      honorLocalDeletes: Boolean(queuedClearBase),
     }
     conversationSaveChainRef.current = enqueueWizardConversationSave(
       conversationSaveChainRef.current,
@@ -274,7 +277,10 @@
           setConversationSaveError(null)
           if (saved.merged) {
             const canonicalMessages = normalizeRemoteWizardMessages(saved.conversation.messages, saved.conversation.executions)
-            const hasExclusiveLocal = hasExclusiveWizardMessages(messagesRef.current, canonicalMessages)
+            const hasExclusiveLocal = hasExclusiveWizardMessages(
+              messagesRef.current,
+              saved.conversation.messages as AgentMessage[],
+            )
             if (!hasExclusiveLocal) {
               skipNextConversationSaveRef.current = true
             }

diff --git a/ui/src/features/agent/wizardConversationPersistence.ts b/ui/src/features/agent/wizardConversationPersistence.ts
--- a/ui/src/features/agent/wizardConversationPersistence.ts
+++ b/ui/src/features/agent/wizardConversationPersistence.ts
@@ -25,6 +25,8 @@
   workspace: string
   captured: WizardConversationPayload
   base?: WizardConversationPayload
+  /** Windowed UI captures omit older ids; only an explicit clear may delete them. */
+  honorLocalDeletes?: boolean
 }
 
 export function resolveWizardConversationHydration(
@@ -172,21 +174,22 @@
   local: WizardConversationPayload,
   base: WizardConversationPayload | undefined,
   canonical: WizardConversationPayload,
+  honorLocalDeletes = true,
 ): WizardConversationPayload {
   return {
     version: 1,
     revision: canonical.revision,
-    messages: mergeQueuedValues(local.messages, base?.messages, canonical.messages),
-    executions: mergeQueuedValues(local.executions, base?.executions, canonical.executions),
+    messages: mergeQueuedValues(local.messages, base?.messages, canonical.messages, honorLocalDeletes),
+    executions: mergeQueuedValues(local.executions, base?.executions, canonical.executions, honorLocalDeletes),
     requestedActions: local.requestedActions === undefined
       ? canonical.requestedActions
-      : mergeQueuedValues(local.requestedActions, base?.requestedActions, canonical.requestedActions),
+      : mergeQueuedValues(local.requestedActions, base?.requestedActions, canonical.requestedActions, honorLocalDeletes),
     executedActions: local.executedActions === undefined
       ? canonical.executedActions
-      : mergeQueuedValues(local.executedActions, base?.executedActions, canonical.executedActions),
+      : mergeQueuedValues(local.executedActions, base?.executedActions, canonical.executedActions, honorLocalDeletes),
     confirmations: local.confirmations === undefined
       ? canonical.confirmations
-      : mergeQueuedValues(local.confirmations, base?.confirmations, canonical.confirmations),
+      : mergeQueuedValues(local.confirmations, base?.confirmations, canonical.confirmations, honorLocalDeletes),
   }
 }
 
@@ -234,6 +237,7 @@
   conversation: WizardConversationPayload,
   transport: WizardConversationTransport = defaultTransport,
   base?: WizardConversationPayload,
+  honorLocalDeletes = true,
 ): Promise<WizardConversationSaveResult> {
   try {
     return {
@@ -244,7 +248,7 @@
     if (!isWizardConversationConflict(error)) throw error
     const remote = await transport.fetch(workspace)
     const merged = base
-      ? mergeQueuedWizardConversationSnapshots(conversation, base, remote)
+      ? mergeQueuedWizardConversationSnapshots(conversation, base, remote, honorLocalDeletes)
       : mergeWizardConversationSnapshots(conversation, remote)
     return {
       conversation: await transport.save(workspace, merged),
@@ -268,10 +272,17 @@
   transport: WizardConversationTransport = defaultTransport,
 ): Promise<WizardConversationSaveResult> {
   const canonical = snapshots.get(write.workspace)
+  const honorLocalDeletes = write.honorLocalDeletes ?? false
   const outgoing = canonical
-    ? mergeQueuedWizardConversationSnapshots(write.captured, write.base, canonical)
+    ? mergeQueuedWizardConversationSnapshots(write.captured, write.base, canonical, honorLocalDeletes)
     : write.captured
-  const saved = await saveWizardConversationWithRecovery(write.workspace, outgoing, transport, canonical)
+  const saved = await saveWizardConversationWithRecovery(
+    write.workspace,
+    outgoing,
+    transport,
+    write.base ?? canonical,
+    honorLocalDeletes,
+  )
   snapshots.set(write.workspace, saved.conversation)
   return saved
 }

diff --git a/ui/src/features/agent/wizardConversationSync.ts b/ui/src/features/agent/wizardConversationSync.ts
--- a/ui/src/features/agent/wizardConversationSync.ts
+++ b/ui/src/features/agent/wizardConversationSync.ts
@@ -61,20 +61,21 @@
 /**
  * Merge two snapshots without duplicating a message id.
  *
- * The remote snapshot is canonical and therefore wins for an id already
- * persisted there. Local-only messages are appended in their existing order;
- * this makes retries idempotent while keeping a user's turn together.
+ * Shared ids keep the local copy so in-flight card and workflow updates are
+ * not replaced by a stale remote snapshot. Remote-only messages stay in
+ * remote order; local-only messages are appended once.
  */
 export function mergeWizardMessages(
   localMessages: WizardSyncMessage[],
   remoteMessages: WizardSyncMessage[],
 ): WizardSyncMessage[] {
+  const localById = new Map(localMessages.map(message => [message.id, message]))
   const merged: WizardSyncMessage[] = []
   const seen = new Set<string>()
   for (const message of remoteMessages) {
     if (!message.id || seen.has(message.id)) continue
     seen.add(message.id)
-    merged.push(message)
+    merged.push(localById.get(message.id) || message)
   }
   for (const message of localMessages) {
     if (!message.id || seen.has(message.id)) continue
@@ -130,7 +131,12 @@
   if (localRevision > remoteRevision) {
     return { source: 'local', messages: localMessages, revision: localRevision }
   }
-  return { source: 'remote', messages: remoteMessages, revision: remoteRevision }
+  const localById = new Map(localMessages.map(message => [message.id, message]))
+  return {
+    source: 'remote',
+    messages: remoteMessages.map(message => localById.get(message.id) || message),
+    revision: remoteRevision,
+  }
 }
 
 /** Follow the footer workspace only after the in-flight turn finishes. */

diff --git a/ui/tests/wizardConversationPersistence.test.mjs b/ui/tests/wizardConversationPersistence.test.mjs
--- a/ui/tests/wizardConversationPersistence.test.mjs
+++ b/ui/tests/wizardConversationPersistence.test.mjs
@@ -15,8 +15,10 @@
   fetchWizardConversation,
 } = await import('../src/api/wizard.ts')
 const {
+  applyRemoteWizardConversation,
   isWizardConversationWriteCurrent,
   hasExclusiveWizardMessages,
+  mergeWizardMessages,
   shouldFollowWizardWorkspace,
 } = await import('../src/features/agent/wizardConversationSync.ts')
 
@@ -282,6 +284,7 @@
     workspace: 'workspace-a',
     captured: capturedClear,
     base: clearBase,
+    honorLocalDeletes: true,
   }, snapshots, transport)
 
   assert.equal(saved.conversation.revision, 3)
@@ -488,6 +491,117 @@
   assert.deepEqual(rebased.conversation.messages, confirmed.messages)
 })
 
+test('a windowed follow-up persist keeps recovered turns outside the UI window', async () => {
+  const recoveredIds = Array.from({ length: 40 }, (_, index) => `recovered-${index}`)
+  const visibleIds = Array.from({ length: 40 }, (_, index) => `visible-${index}`)
+  const fullIds = [...recoveredIds, ...visibleIds]
+  const canonical = payload(5, fullIds)
+  const capturedWindow = payload(5, visibleIds)
+  const snapshots = new Map([['workspace-a', clone(canonical)]])
+  const transport = {
+    async fetch() { return clone(canonical) },
+    async save(_workspace, conversation) {
+      assert.deepEqual(conversation.messages.map(message => message.id), fullIds)
+      return { ...clone(conversation), revision: 6 }
+    },
+  }
+
+  const saved = await persistQueuedWizardConversation({
+    workspace: 'workspace-a',
+    captured: capturedWindow,
+    base: canonical,
+  }, snapshots, transport)
+
+  assert.deepEqual(saved.conversation.messages.map(message => message.id), fullIds)
+  assert.equal(hasExclusiveWizardMessages(capturedWindow.messages, saved.conversation.messages), false)
+})
+
+test('a conflict retry after a predecessor clear does not resurrect deleted turns', async () => {
+  const clearBase = payload(1, ['cleared-user', 'cleared-assistant'])
+  const persistStart = payload(2, ['welcome-after-clear'])
+  const capturedClear = payload(1, ['welcome-after-clear'])
+  const snapshots = new Map([['workspace-a', clone(persistStart)]])
+  let canonical = payload(3, ['cleared-user', 'cleared-assistant', 'other-tab-user'])
+  let saves = 0
+  const transport = {
+    async fetch() { return clone(canonical) },
+    async save(_workspace, conversation) {
+      saves += 1
+      if (saves === 1) throw revisionConflict(conversation.revision, canonical.revision)
+      assert.deepEqual(conversation.messages.map(message => message.id), [
+        'other-tab-user',
+        'welcome-after-clear',
+      ])
+      canonical = { ...clone(conversation), revision: 4 }
+      return clone(canonical)
+    },
+  }
+
+  const saved = await persistQueuedWizardConversation({
+    workspace: 'workspace-a',
+    captured: capturedClear,
+    base: clearBase,
+    honorLocalDeletes: true,
+  }, snapshots, transport)
+
+  assert.equal(saved.merged, true)
+  assert.equal(saves, 2)
+  assert.deepEqual(saved.conversation.messages.map(message => message.id), [
+    'other-tab-user',
+    'welcome-after-clear',
+  ])
+})
+
+test('shared-id merge keeps newer local card and workflow text', () => {
+  const local = [
+    {
+      id: 'shared',
+      role: 'assistant',
+      text: 'workflow updated',
+      createdAt: 1,
+      cards: [{ id: 'card-1', state: 'completed' }],
+    },
+    { id: 'local-only', role: 'user', text: 'new turn', createdAt: 2 },
+  ]
+  const remote = [
+    {
+      id: 'shared',
+      role: 'assistant',
+      text: 'workflow stale',
+      createdAt: 1,
+      cards: [{ id: 'card-1', state: 'running' }],
+    },
+    { id: 'remote-only', role: 'assistant', text: 'from other tab', createdAt: 3 },
+  ]
+
+  const merged = mergeWizardMessages(local, remote)
+  assert.equal(merged.find(message => message.id === 'shared').text, 'workflow updated')
+  assert.equal(merged.find(message => message.id === 'shared').cards[0].state, 'completed')
+  assert.deepEqual(merged.map(message => message.id), ['shared', 'remote-only', 'local-only'])
+
+  const hydrated = applyRemoteWizardConversation({
+    localMessages: [{
+      id: 'shared',
+      role: 'assistant',
+      text: 'workflow updated',
+      createdAt: 1,
+      cards: [{ id: 'card-1', state: 'completed' }],
+    }],
+    localRevision: 4,
+    remoteMessages: [{
+      id: 'shared',
+      role: 'assistant',
+      text: 'workflow stale',
+      createdAt: 1,
+      cards: [{ id: 'card-1', state: 'running' }],
+    }],
+    remoteRevision: 5,
+  })
+  assert.equal(hydrated.source, 'remote')
+  assert.equal(hydrated.messages[0].text, 'workflow updated')
+  assert.equal(hydrated.messages[0].cards[0].state, 'completed')
+})
+
 test('explicit clear deletes base turns while retaining concurrent canonical additions', () => {
   const stale = payload(7, ['old-user', 'old-assistant'])
   const confirmed = payload(8, ['old-user', 'old-assistant', 'concurrent-user'])

You can send follow-ups to the cloud agent here.

Comment thread ui/src/features/agent/AgentAssistantPanel.tsx Outdated
Comment thread ui/src/features/agent/wizardConversationPersistence.ts Outdated
Comment thread ui/src/features/agent/wizardConversationSync.ts
@IAnMove

IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Clear undone by in-flight save rebase
    • Post-save rebase now passes honorLocalDeletes when the write itself was a Clear or a later Clear is still pending, so in-flight persist no longer restores deleted turns.

Create PR

Or push these changes by commenting:

@cursor push 9b2242297f
Preview (9b2242297f)
diff --git a/ui/src/features/agent/AgentAssistantPanel.tsx b/ui/src/features/agent/AgentAssistantPanel.tsx
--- a/ui/src/features/agent/AgentAssistantPanel.tsx
+++ b/ui/src/features/agent/AgentAssistantPanel.tsx
@@ -272,12 +272,17 @@
           if (!mountedRef.current || !isWizardConversationWriteCurrent(conversationWorkspaceRef.current, conversationWorkspace)) return
           setConversationSaveError(null)
           const visibleMessages = messagesRef.current
+          const pendingClearBase = conversationClearBasesRef.current.get(conversationWorkspace)
           const rebased = rebaseStaleWizardConversationHydration({
             ...queuedWrite.captured,
             revision: saved.conversation.revision,
             messages: visibleMessages,
             executions: visibleMessages.flatMap(message => message.cards || []),
-          }, queuedWrite.captured, saved.conversation)
+          }, queuedWrite.captured, saved.conversation, {
+            // A later Clear must not look like a stale-cache omission when this
+            // in-flight save rebases the now-empty thread over its captured turns.
+            honorLocalDeletes: Boolean(queuedWrite.honorLocalDeletes || pendingClearBase),
+          })
           if (saved.merged || rebased.needsPersist) {
             skipNextConversationSaveRef.current = !rebased.needsPersist
             setMessages(normalizeRemoteWizardMessages(

You can send follow-ups to the cloud agent here.

Comment thread ui/src/features/agent/AgentAssistantPanel.tsx
@IAnMove

IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

@cursor review

@IAnMove

IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

2 issues from previous reviews remain unresolved.

Fix All in Cursor

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit c4b627f. Configure here.

@IAnMove
IAnMove merged commit 98b824a into main Sep 3, 2026
5 checks passed
@IAnMove
IAnMove deleted the fix/wizard-conversation-conflicts branch September 5, 2026 11:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant